mirror of https://github.com/tasks/tasks
Merge tag '13.10'
commit
33798b3255
@ -1,791 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2022 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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a copy of androidx.compose.foundation.gestures.AnchoredDraggable until that API is
|
|
||||||
* promoted to stable in foundation. Any changes there should be replicated here.
|
|
||||||
*/
|
|
||||||
import androidx.annotation.FloatRange
|
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
|
||||||
import androidx.compose.animation.core.SpringSpec
|
|
||||||
import androidx.compose.animation.core.animate
|
|
||||||
import androidx.compose.foundation.MutatePriority
|
|
||||||
import androidx.compose.foundation.gestures.DragScope
|
|
||||||
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.foundation.layout.offset
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.runtime.structuralEqualityPolicy
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structure that represents the anchors of a [AnchoredDraggableState].
|
|
||||||
*
|
|
||||||
* See the DraggableAnchors factory method to construct drag anchors using a default implementation.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal interface DraggableAnchors<T> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the anchor position for an associated [value]
|
|
||||||
*
|
|
||||||
* @return The position of the anchor, or [Float.NaN] if the anchor does not exist
|
|
||||||
*/
|
|
||||||
fun positionOf(value: T): Float
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether there is an anchor position associated with the [value]
|
|
||||||
*
|
|
||||||
* @param value The value to look up
|
|
||||||
* @return true if there is an anchor for this value, false if there is no anchor for this value
|
|
||||||
*/
|
|
||||||
fun hasAnchorFor(value: T): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the closest anchor to the [position].
|
|
||||||
*
|
|
||||||
* @param position The position to start searching from
|
|
||||||
*
|
|
||||||
* @return The closest anchor or null if the anchors are empty
|
|
||||||
*/
|
|
||||||
fun closestAnchor(position: Float): T?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the closest anchor to the [position], in the specified direction.
|
|
||||||
*
|
|
||||||
* @param position The position to start searching from
|
|
||||||
* @param searchUpwards Whether to search upwards from the current position or downwards
|
|
||||||
*
|
|
||||||
* @return The closest anchor or null if the anchors are empty
|
|
||||||
*/
|
|
||||||
fun closestAnchor(position: Float, searchUpwards: Boolean): T?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty.
|
|
||||||
*/
|
|
||||||
fun minAnchor(): Float
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty.
|
|
||||||
*/
|
|
||||||
fun maxAnchor(): Float
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of anchors
|
|
||||||
*/
|
|
||||||
val size: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
|
|
||||||
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
|
|
||||||
* [DraggableAnchors] instance later on.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal class DraggableAnchorsConfig<T> {
|
|
||||||
|
|
||||||
internal val anchors = mutableMapOf<T, Float>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the anchor position for [this] anchor.
|
|
||||||
*
|
|
||||||
* @param position The anchor position.
|
|
||||||
*/
|
|
||||||
@Suppress("BuilderSetStyle")
|
|
||||||
infix fun T.at(position: Float) {
|
|
||||||
anchors[this] = position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new [DraggableAnchors] instance using a builder function.
|
|
||||||
*
|
|
||||||
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
|
|
||||||
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
|
|
||||||
* function.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal fun <T : Any> DraggableAnchors(
|
|
||||||
builder: DraggableAnchorsConfig<T>.() -> Unit
|
|
||||||
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable drag gestures between a set of predefined values.
|
|
||||||
*
|
|
||||||
* When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag
|
|
||||||
* delta. You should use this offset to move your content accordingly (see [Modifier.offset]).
|
|
||||||
* When the drag ends, the offset will be animated to one of the anchors and when that anchor is
|
|
||||||
* reached, the value of the [AnchoredDraggableState] will also be updated to the value
|
|
||||||
* corresponding to the new anchor.
|
|
||||||
*
|
|
||||||
* Dragging is constrained between the minimum and maximum anchors.
|
|
||||||
*
|
|
||||||
* @param state The associated [AnchoredDraggableState].
|
|
||||||
* @param orientation The orientation in which the [anchoredDraggable] can be dragged.
|
|
||||||
* @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input.
|
|
||||||
* @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom
|
|
||||||
* drag will behave like bottom to top, and a left to right drag will behave like right to left.
|
|
||||||
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
|
|
||||||
* the internal [Modifier.draggable].
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal fun <T> Modifier.anchoredDraggable(
|
|
||||||
state: AnchoredDraggableState<T>,
|
|
||||||
orientation: Orientation,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
reverseDirection: Boolean = false,
|
|
||||||
interactionSource: MutableInteractionSource? = null
|
|
||||||
) = draggable(
|
|
||||||
state = state.draggableState,
|
|
||||||
orientation = orientation,
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
reverseDirection = reverseDirection,
|
|
||||||
startDragImmediately = state.isAnimationRunning,
|
|
||||||
onDragStopped = { velocity -> launch { state.settle(velocity) } }
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to
|
|
||||||
* a new value.
|
|
||||||
*
|
|
||||||
* @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the
|
|
||||||
* access to this scope.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal interface AnchoredDragScope {
|
|
||||||
/**
|
|
||||||
* Assign a new value for an offset value for [AnchoredDraggableState].
|
|
||||||
*
|
|
||||||
* @param newOffset new value for [AnchoredDraggableState.offset].
|
|
||||||
* @param lastKnownVelocity last known velocity (if known)
|
|
||||||
*/
|
|
||||||
fun dragTo(
|
|
||||||
newOffset: Float,
|
|
||||||
lastKnownVelocity: Float = 0f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State of the [anchoredDraggable] modifier.
|
|
||||||
* Use the constructor overload with anchors if the anchors are defined in composition, or update
|
|
||||||
* the anchors using [updateAnchors].
|
|
||||||
*
|
|
||||||
* This contains necessary information about any ongoing drag or animation and provides methods
|
|
||||||
* to change the state either immediately or by starting an animation.
|
|
||||||
*
|
|
||||||
* @param initialValue The initial value of the state.
|
|
||||||
* @param positionalThreshold The positional threshold, in px, to be used when calculating the
|
|
||||||
* target state while a drag is in progress and when settling after the drag ends. This is the
|
|
||||||
* distance from the start of a transition. It will be, depending on the direction of the
|
|
||||||
* interaction, added or subtracted from/to the origin offset. It should always be a positive value.
|
|
||||||
* @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to
|
|
||||||
* exceed in order to animate to the next state, even if the [positionalThreshold] has not been
|
|
||||||
* reached.
|
|
||||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
|
||||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal class AnchoredDraggableState<T>(
|
|
||||||
initialValue: T,
|
|
||||||
internal val positionalThreshold: (totalDistance: Float) -> Float,
|
|
||||||
internal val velocityThreshold: () -> Float,
|
|
||||||
val animationSpec: AnimationSpec<Float>,
|
|
||||||
internal val confirmValueChange: (newValue: T) -> Boolean = { true }
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct an [AnchoredDraggableState] instance with anchors.
|
|
||||||
*
|
|
||||||
* @param initialValue The initial value of the state.
|
|
||||||
* @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later.
|
|
||||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
|
||||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state
|
|
||||||
* change.
|
|
||||||
* @param positionalThreshold The positional threshold, in px, to be used when calculating the
|
|
||||||
* target state while a drag is in progress and when settling after the drag ends. This is the
|
|
||||||
* distance from the start of a transition. It will be, depending on the direction of the
|
|
||||||
* interaction, added or subtracted from/to the origin offset. It should always be a positive
|
|
||||||
* value.
|
|
||||||
* @param velocityThreshold The velocity threshold (in px per second) that the end velocity has
|
|
||||||
* to exceed in order to animate to the next state, even if the [positionalThreshold] has not
|
|
||||||
* been reached.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
constructor(
|
|
||||||
initialValue: T,
|
|
||||||
anchors: DraggableAnchors<T>,
|
|
||||||
positionalThreshold: (totalDistance: Float) -> Float,
|
|
||||||
velocityThreshold: () -> Float,
|
|
||||||
animationSpec: AnimationSpec<Float>,
|
|
||||||
confirmValueChange: (newValue: T) -> Boolean = { true }
|
|
||||||
) : this(
|
|
||||||
initialValue,
|
|
||||||
positionalThreshold,
|
|
||||||
velocityThreshold,
|
|
||||||
animationSpec,
|
|
||||||
confirmValueChange
|
|
||||||
) {
|
|
||||||
this.anchors = anchors
|
|
||||||
trySnapTo(initialValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val dragMutex = InternalMutatorMutex()
|
|
||||||
|
|
||||||
internal val draggableState = object : DraggableState {
|
|
||||||
|
|
||||||
private val dragScope = object : DragScope {
|
|
||||||
override fun dragBy(pixels: Float) {
|
|
||||||
with(anchoredDragScope) {
|
|
||||||
dragTo(newOffsetForDelta(pixels))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun drag(
|
|
||||||
dragPriority: MutatePriority,
|
|
||||||
block: suspend DragScope.() -> Unit
|
|
||||||
) {
|
|
||||||
this@AnchoredDraggableState.anchoredDrag(dragPriority) {
|
|
||||||
with(dragScope) { block() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispatchRawDelta(delta: Float) {
|
|
||||||
this@AnchoredDraggableState.dispatchRawDelta(delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current value of the [AnchoredDraggableState].
|
|
||||||
*/
|
|
||||||
var currentValue: T by mutableStateOf(initialValue)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The target value. This is the closest value to the current offset, taking into account
|
|
||||||
* positional thresholds. If no interactions like animations or drags are in progress, this
|
|
||||||
* will be the current value.
|
|
||||||
*/
|
|
||||||
val targetValue: T by derivedStateOf {
|
|
||||||
dragTarget ?: run {
|
|
||||||
val currentOffset = offset
|
|
||||||
if (!currentOffset.isNaN()) {
|
|
||||||
computeTarget(currentOffset, currentValue, velocity = 0f)
|
|
||||||
} else currentValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The closest value in the swipe direction from the current offset, not considering thresholds.
|
|
||||||
* If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if
|
|
||||||
* specified).
|
|
||||||
*/
|
|
||||||
internal val closestValue: T by derivedStateOf {
|
|
||||||
dragTarget ?: run {
|
|
||||||
val currentOffset = offset
|
|
||||||
if (!currentOffset.isNaN()) {
|
|
||||||
computeTargetWithoutThresholds(currentOffset, currentValue)
|
|
||||||
} else currentValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current offset, or [Float.NaN] if it has not been initialized yet.
|
|
||||||
*
|
|
||||||
* The offset will be initialized when the anchors are first set through [updateAnchors].
|
|
||||||
*
|
|
||||||
* Strongly consider using [requireOffset] which will throw if the offset is read before it is
|
|
||||||
* initialized. This helps catch issues early in your workflow.
|
|
||||||
*/
|
|
||||||
var offset: Float by mutableFloatStateOf(Float.NaN)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require the current offset.
|
|
||||||
*
|
|
||||||
* @see offset
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException If the offset has not been initialized yet
|
|
||||||
*/
|
|
||||||
fun requireOffset(): Float {
|
|
||||||
check(!offset.isNaN()) {
|
|
||||||
"The offset was read before being initialized. Did you access the offset in a phase " +
|
|
||||||
"before layout, like effects or composition?"
|
|
||||||
}
|
|
||||||
return offset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether an animation is currently in progress.
|
|
||||||
*/
|
|
||||||
val isAnimationRunning: Boolean get() = dragTarget != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f]
|
|
||||||
* bounds, or 1f if the [AnchoredDraggableState] is in a settled state.
|
|
||||||
*/
|
|
||||||
@get:FloatRange(from = 0.0, to = 1.0)
|
|
||||||
val progress: Float by derivedStateOf(structuralEqualityPolicy()) {
|
|
||||||
val a = anchors.positionOf(currentValue)
|
|
||||||
val b = anchors.positionOf(closestValue)
|
|
||||||
val distance = abs(b - a)
|
|
||||||
if (!distance.isNaN() && distance > 1e-6f) {
|
|
||||||
val progress = (this.requireOffset() - a) / (b - a)
|
|
||||||
// If we are very close to 0f or 1f, we round to the closest
|
|
||||||
if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress
|
|
||||||
} else 1f
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The velocity of the last known animation. Gets reset to 0f when an animation completes
|
|
||||||
* successfully, but does not get reset when an animation gets interrupted.
|
|
||||||
* You can use this value to provide smooth reconciliation behavior when re-targeting an
|
|
||||||
* animation.
|
|
||||||
*/
|
|
||||||
var lastVelocity: Float by mutableFloatStateOf(0f)
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var dragTarget: T? by mutableStateOf(null)
|
|
||||||
|
|
||||||
var anchors: DraggableAnchors<T> by mutableStateOf(emptyDraggableAnchors())
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget],
|
|
||||||
* otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new
|
|
||||||
* anchors.
|
|
||||||
*
|
|
||||||
* <b>If your anchors depend on the size of the layout, updateAnchors should be called in the
|
|
||||||
* layout (placement) phase, e.g. through Modifier.onSizeChanged.</b> This ensures that the
|
|
||||||
* state is set up within the same frame.
|
|
||||||
* For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to
|
|
||||||
* be called from side effects or layout.
|
|
||||||
*
|
|
||||||
* @param newAnchors The new anchors.
|
|
||||||
* @param newTarget The new target, by default the closest anchor or the current target if there
|
|
||||||
* are no anchors.
|
|
||||||
*/
|
|
||||||
fun updateAnchors(
|
|
||||||
newAnchors: DraggableAnchors<T>,
|
|
||||||
newTarget: T = if (!offset.isNaN()) {
|
|
||||||
newAnchors.closestAnchor(offset) ?: targetValue
|
|
||||||
} else targetValue
|
|
||||||
) {
|
|
||||||
if (anchors != newAnchors) {
|
|
||||||
anchors = newAnchors
|
|
||||||
// Attempt to snap. If nobody is holding the lock, we can immediately update the offset.
|
|
||||||
// If anybody is holding the lock, we send a signal to restart the ongoing work with the
|
|
||||||
// updated anchors.
|
|
||||||
val snapSuccessful = trySnapTo(newTarget)
|
|
||||||
if (!snapSuccessful) {
|
|
||||||
dragTarget = newTarget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the closest anchor, taking into account the [velocityThreshold] and
|
|
||||||
* [positionalThreshold], and settle at it with an animation.
|
|
||||||
*
|
|
||||||
* If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and
|
|
||||||
* [positionalThreshold] will be the target. If the [velocity] is higher than the
|
|
||||||
* [velocityThreshold], the [positionalThreshold] will <b>not</b> be considered and the next
|
|
||||||
* anchor in the direction indicated by the sign of the [velocity] will be the target.
|
|
||||||
*/
|
|
||||||
suspend fun settle(velocity: Float) {
|
|
||||||
val previousValue = this.currentValue
|
|
||||||
val targetValue = computeTarget(
|
|
||||||
offset = requireOffset(),
|
|
||||||
currentValue = previousValue,
|
|
||||||
velocity = velocity
|
|
||||||
)
|
|
||||||
if (confirmValueChange(targetValue)) {
|
|
||||||
animateTo(targetValue, velocity)
|
|
||||||
} else {
|
|
||||||
// If the user vetoed the state change, rollback to the previous state.
|
|
||||||
animateTo(previousValue, velocity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeTarget(
|
|
||||||
offset: Float,
|
|
||||||
currentValue: T,
|
|
||||||
velocity: Float
|
|
||||||
): T {
|
|
||||||
val currentAnchors = anchors
|
|
||||||
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
|
|
||||||
val velocityThresholdPx = velocityThreshold()
|
|
||||||
return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) {
|
|
||||||
currentValue
|
|
||||||
} else if (currentAnchorPosition < offset) {
|
|
||||||
// Swiping from lower to upper (positive).
|
|
||||||
if (velocity >= velocityThresholdPx) {
|
|
||||||
currentAnchors.closestAnchor(offset, true)!!
|
|
||||||
} else {
|
|
||||||
val upper = currentAnchors.closestAnchor(offset, true)!!
|
|
||||||
val distance = abs(currentAnchors.positionOf(upper) - currentAnchorPosition)
|
|
||||||
val relativeThreshold = abs(positionalThreshold(distance))
|
|
||||||
val absoluteThreshold = abs(currentAnchorPosition + relativeThreshold)
|
|
||||||
if (offset < absoluteThreshold) currentValue else upper
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Swiping from upper to lower (negative).
|
|
||||||
if (velocity <= -velocityThresholdPx) {
|
|
||||||
currentAnchors.closestAnchor(offset, false)!!
|
|
||||||
} else {
|
|
||||||
val lower = currentAnchors.closestAnchor(offset, false)!!
|
|
||||||
val distance = abs(currentAnchorPosition - currentAnchors.positionOf(lower))
|
|
||||||
val relativeThreshold = abs(positionalThreshold(distance))
|
|
||||||
val absoluteThreshold = abs(currentAnchorPosition - relativeThreshold)
|
|
||||||
if (offset < 0) {
|
|
||||||
// For negative offsets, larger absolute thresholds are closer to lower anchors
|
|
||||||
// than smaller ones.
|
|
||||||
if (abs(offset) < absoluteThreshold) currentValue else lower
|
|
||||||
} else {
|
|
||||||
if (offset > absoluteThreshold) currentValue else lower
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeTargetWithoutThresholds(
|
|
||||||
offset: Float,
|
|
||||||
currentValue: T,
|
|
||||||
): T {
|
|
||||||
val currentAnchors = anchors
|
|
||||||
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
|
|
||||||
return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) {
|
|
||||||
currentValue
|
|
||||||
} else if (currentAnchorPosition < offset) {
|
|
||||||
currentAnchors.closestAnchor(offset, true) ?: currentValue
|
|
||||||
} else {
|
|
||||||
currentAnchors.closestAnchor(offset, false) ?: currentValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val anchoredDragScope: AnchoredDragScope = object : AnchoredDragScope {
|
|
||||||
override fun dragTo(newOffset: Float, lastKnownVelocity: Float) {
|
|
||||||
offset = newOffset
|
|
||||||
lastVelocity = lastKnownVelocity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this function to take control of drag logic and perform anchored drag with the latest
|
|
||||||
* anchors.
|
|
||||||
*
|
|
||||||
* All actions that change the [offset] of this [AnchoredDraggableState] must be performed
|
|
||||||
* within an [anchoredDrag] block (even if they don't call any other methods on this object)
|
|
||||||
* in order to guarantee that mutual exclusion is enforced.
|
|
||||||
*
|
|
||||||
* If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
|
|
||||||
* drag, the ongoing drag will be cancelled.
|
|
||||||
*
|
|
||||||
* <b>If the [anchors] change while the [block] is being executed, it will be cancelled and
|
|
||||||
* re-executed with the latest anchors and target.</b> This allows you to target the correct
|
|
||||||
* state.
|
|
||||||
*
|
|
||||||
* @param dragPriority of the drag operation
|
|
||||||
* @param block perform anchored drag given the current anchor provided
|
|
||||||
*/
|
|
||||||
suspend fun anchoredDrag(
|
|
||||||
dragPriority: MutatePriority = MutatePriority.Default,
|
|
||||||
block: suspend AnchoredDragScope.(anchors: DraggableAnchors<T>) -> Unit
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
dragMutex.mutate(dragPriority) {
|
|
||||||
restartable(inputs = { anchors }) { latestAnchors ->
|
|
||||||
anchoredDragScope.block(latestAnchors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
val closest = anchors.closestAnchor(offset)
|
|
||||||
if (closest != null &&
|
|
||||||
abs(offset - anchors.positionOf(closest)) <= 0.5f &&
|
|
||||||
confirmValueChange.invoke(closest)
|
|
||||||
) {
|
|
||||||
currentValue = closest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this function to take control of drag logic and perform anchored drag with the latest
|
|
||||||
* anchors and target.
|
|
||||||
*
|
|
||||||
* All actions that change the [offset] of this [AnchoredDraggableState] must be performed
|
|
||||||
* within an [anchoredDrag] block (even if they don't call any other methods on this object)
|
|
||||||
* in order to guarantee that mutual exclusion is enforced.
|
|
||||||
*
|
|
||||||
* This overload allows the caller to hint the target value that this [anchoredDrag] is intended
|
|
||||||
* to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so
|
|
||||||
* consumers can reflect it in their UIs.
|
|
||||||
*
|
|
||||||
* <b>If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being
|
|
||||||
* executed, it will be cancelled and re-executed with the latest anchors and target.</b> This
|
|
||||||
* allows you to target the correct state.
|
|
||||||
*
|
|
||||||
* If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
|
|
||||||
* drag, the ongoing drag will be cancelled.
|
|
||||||
*
|
|
||||||
* @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to
|
|
||||||
* @param dragPriority of the drag operation
|
|
||||||
* @param block perform anchored drag given the current anchor provided
|
|
||||||
*/
|
|
||||||
suspend fun anchoredDrag(
|
|
||||||
targetValue: T,
|
|
||||||
dragPriority: MutatePriority = MutatePriority.Default,
|
|
||||||
block: suspend AnchoredDragScope.(anchors: DraggableAnchors<T>, targetValue: T) -> Unit
|
|
||||||
) {
|
|
||||||
if (anchors.hasAnchorFor(targetValue)) {
|
|
||||||
try {
|
|
||||||
dragMutex.mutate(dragPriority) {
|
|
||||||
dragTarget = targetValue
|
|
||||||
restartable(
|
|
||||||
inputs = { anchors to this@AnchoredDraggableState.targetValue }
|
|
||||||
) { (latestAnchors, latestTarget) ->
|
|
||||||
anchoredDragScope.block(latestAnchors, latestTarget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
dragTarget = null
|
|
||||||
val closest = anchors.closestAnchor(offset)
|
|
||||||
if (closest != null &&
|
|
||||||
abs(offset - anchors.positionOf(closest)) <= 0.5f &&
|
|
||||||
confirmValueChange.invoke(closest)
|
|
||||||
) {
|
|
||||||
currentValue = closest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Todo: b/283467401, revisit this behavior
|
|
||||||
currentValue = targetValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun newOffsetForDelta(delta: Float) =
|
|
||||||
((if (offset.isNaN()) 0f else offset) + delta)
|
|
||||||
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState].
|
|
||||||
*
|
|
||||||
* @return The delta the consumed by the [AnchoredDraggableState]
|
|
||||||
*/
|
|
||||||
fun dispatchRawDelta(delta: Float): Float {
|
|
||||||
val newOffset = newOffsetForDelta(delta)
|
|
||||||
val oldOffset = if (offset.isNaN()) 0f else offset
|
|
||||||
offset = newOffset
|
|
||||||
return newOffset - oldOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag
|
|
||||||
* transaction like a drag or an animation is progress. If there is another interaction in
|
|
||||||
* progress, the suspending [snapTo] overload needs to be used.
|
|
||||||
*
|
|
||||||
* @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
|
|
||||||
*/
|
|
||||||
private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate {
|
|
||||||
with(anchoredDragScope) {
|
|
||||||
val targetOffset = anchors.positionOf(targetValue)
|
|
||||||
if (!targetOffset.isNaN()) {
|
|
||||||
dragTo(targetOffset)
|
|
||||||
dragTarget = null
|
|
||||||
}
|
|
||||||
currentValue = targetValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* The default [Saver] implementation for [AnchoredDraggableState].
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
fun <T : Any> Saver(
|
|
||||||
animationSpec: AnimationSpec<Float>,
|
|
||||||
confirmValueChange: (T) -> Boolean,
|
|
||||||
positionalThreshold: (distance: Float) -> Float,
|
|
||||||
velocityThreshold: () -> Float,
|
|
||||||
) = Saver<AnchoredDraggableState<T>, T>(
|
|
||||||
save = { it.currentValue },
|
|
||||||
restore = {
|
|
||||||
AnchoredDraggableState(
|
|
||||||
initialValue = it,
|
|
||||||
animationSpec = animationSpec,
|
|
||||||
confirmValueChange = confirmValueChange,
|
|
||||||
positionalThreshold = positionalThreshold,
|
|
||||||
velocityThreshold = velocityThreshold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snap to a [targetValue] without any animation.
|
|
||||||
* If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.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
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal suspend fun <T> AnchoredDraggableState<T>.snapTo(targetValue: T) {
|
|
||||||
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
|
|
||||||
val targetOffset = anchors.positionOf(latestTarget)
|
|
||||||
if (!targetOffset.isNaN()) dragTo(targetOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Animate to a [targetValue].
|
|
||||||
* If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.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
|
|
||||||
* @param velocity The velocity the animation should start with
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal suspend fun <T> AnchoredDraggableState<T>.animateTo(
|
|
||||||
targetValue: T,
|
|
||||||
velocity: Float = this.lastVelocity,
|
|
||||||
) {
|
|
||||||
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
|
|
||||||
val targetOffset = anchors.positionOf(latestTarget)
|
|
||||||
if (!targetOffset.isNaN()) {
|
|
||||||
var prev = if (offset.isNaN()) 0f else offset
|
|
||||||
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
|
|
||||||
// Our onDrag coerces the value within the bounds, but an animation may
|
|
||||||
// overshoot, for example a spring animation or an overshooting interpolator
|
|
||||||
// We respect the user's intention and allow the overshoot, but still use
|
|
||||||
// DraggableState's drag for its mutex.
|
|
||||||
dragTo(value, velocity)
|
|
||||||
prev = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contains useful defaults for [anchoredDraggable] and [AnchoredDraggableState].
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
internal object AnchoredDraggableDefaults {
|
|
||||||
/**
|
|
||||||
* The default animation used by [AnchoredDraggableState].
|
|
||||||
*/
|
|
||||||
@get:ExperimentalMaterial3Api
|
|
||||||
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
val AnimationSpec = SpringSpec<Float>()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class AnchoredDragFinishedSignal : CancellationException() {
|
|
||||||
override fun fillInStackTrace(): Throwable {
|
|
||||||
stackTrace = emptyArray()
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <I> restartable(inputs: () -> I, block: suspend (I) -> Unit) {
|
|
||||||
try {
|
|
||||||
coroutineScope {
|
|
||||||
var previousDrag: Job? = null
|
|
||||||
snapshotFlow(inputs)
|
|
||||||
.collect { latestInputs ->
|
|
||||||
previousDrag?.apply {
|
|
||||||
cancel(AnchoredDragFinishedSignal())
|
|
||||||
join()
|
|
||||||
}
|
|
||||||
previousDrag = launch(start = CoroutineStart.UNDISPATCHED) {
|
|
||||||
block(latestInputs)
|
|
||||||
this@coroutineScope.cancel(AnchoredDragFinishedSignal())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (anchoredDragFinished: AnchoredDragFinishedSignal) {
|
|
||||||
// Ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> emptyDraggableAnchors() = MapDraggableAnchors<T>(emptyMap())
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
|
|
||||||
|
|
||||||
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
|
|
||||||
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
|
|
||||||
|
|
||||||
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
|
|
||||||
abs(position - it.value)
|
|
||||||
}?.key
|
|
||||||
|
|
||||||
override fun closestAnchor(
|
|
||||||
position: Float,
|
|
||||||
searchUpwards: Boolean
|
|
||||||
): T? {
|
|
||||||
return anchors.minByOrNull { (_, anchor) ->
|
|
||||||
val delta = if (searchUpwards) anchor - position else position - anchor
|
|
||||||
if (delta < 0) Float.POSITIVE_INFINITY else delta
|
|
||||||
}?.key
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
|
|
||||||
|
|
||||||
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
|
|
||||||
|
|
||||||
override val size: Int
|
|
||||||
get() = anchors.size
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other !is MapDraggableAnchors<*>) return false
|
|
||||||
|
|
||||||
return anchors == other.anchors
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode() = 31 * anchors.hashCode()
|
|
||||||
|
|
||||||
override fun toString() = "MapDraggableAnchors($anchors)"
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
// ktlint-disable filename
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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
|
|
||||||
|
|
||||||
/* Copy of androidx.compose.material.ActualJvm, mirrored from Foundation. This is used for the
|
|
||||||
M2/M3-internal copy of MutatorMutex.
|
|
||||||
*/
|
|
||||||
internal typealias InternalAtomicReference<V> =
|
|
||||||
java.util.concurrent.atomic.AtomicReference<V>
|
|
@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.MutatePriority
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutual exclusion for UI state mutation over time.
|
|
||||||
*
|
|
||||||
* [mutate] permits interruptible state mutation over time using a standard [MutatePriority].
|
|
||||||
* A [InternalMutatorMutex] enforces that only a single writer can be active at a time for a particular
|
|
||||||
* state resource. Instead of queueing callers that would acquire the lock like a traditional
|
|
||||||
* [Mutex], new attempts to [mutate] the guarded state will either cancel the current mutator or
|
|
||||||
* if the current mutator has a higher priority, the new caller will throw [CancellationException].
|
|
||||||
*
|
|
||||||
* [InternalMutatorMutex] should be used for implementing hoisted state objects that many mutators may
|
|
||||||
* want to manipulate over time such that those mutators can coordinate with one another. The
|
|
||||||
* [InternalMutatorMutex] instance should be hidden as an implementation detail. For example:
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
internal class InternalMutatorMutex {
|
|
||||||
private class Mutator(val priority: MutatePriority, val job: Job) {
|
|
||||||
fun canInterrupt(other: Mutator) = priority >= other.priority
|
|
||||||
|
|
||||||
fun cancel() = job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val currentMutator = InternalAtomicReference<Mutator?>(null)
|
|
||||||
private val mutex = Mutex()
|
|
||||||
|
|
||||||
private fun tryMutateOrCancel(mutator: Mutator) {
|
|
||||||
while (true) {
|
|
||||||
val oldMutator = currentMutator.get()
|
|
||||||
if (oldMutator == null || mutator.canInterrupt(oldMutator)) {
|
|
||||||
if (currentMutator.compareAndSet(oldMutator, mutator)) {
|
|
||||||
oldMutator?.cancel()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else throw CancellationException("Current mutation had a higher priority")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce that only a single caller may be active at a time.
|
|
||||||
*
|
|
||||||
* If [mutate] is called while another call to [mutate] or [mutateWith] is in progress, their
|
|
||||||
* [priority] values are compared. If the new caller has a [priority] equal to or higher than
|
|
||||||
* the call in progress, the call in progress will be cancelled, throwing
|
|
||||||
* [CancellationException] and the new caller's [block] will be invoked. If the call in
|
|
||||||
* progress had a higher [priority] than the new caller, the new caller will throw
|
|
||||||
* [CancellationException] without invoking [block].
|
|
||||||
*
|
|
||||||
* @param priority the priority of this mutation; [MutatePriority.Default] by default.
|
|
||||||
* Higher priority mutations will interrupt lower priority mutations.
|
|
||||||
* @param block mutation code to run mutually exclusive with any other call to [mutate],
|
|
||||||
* [mutateWith] or [tryMutate].
|
|
||||||
*/
|
|
||||||
suspend fun <R> mutate(
|
|
||||||
priority: MutatePriority = MutatePriority.Default,
|
|
||||||
block: suspend () -> R
|
|
||||||
) = coroutineScope {
|
|
||||||
val mutator = Mutator(priority, coroutineContext[Job]!!)
|
|
||||||
|
|
||||||
tryMutateOrCancel(mutator)
|
|
||||||
|
|
||||||
mutex.withLock {
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
currentMutator.compareAndSet(mutator, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce that only a single caller may be active at a time.
|
|
||||||
*
|
|
||||||
* If [mutateWith] is called while another call to [mutate] or [mutateWith] is in progress,
|
|
||||||
* their [priority] values are compared. If the new caller has a [priority] equal to or
|
|
||||||
* higher than the call in progress, the call in progress will be cancelled, throwing
|
|
||||||
* [CancellationException] and the new caller's [block] will be invoked. If the call in
|
|
||||||
* progress had a higher [priority] than the new caller, the new caller will throw
|
|
||||||
* [CancellationException] without invoking [block].
|
|
||||||
*
|
|
||||||
* This variant of [mutate] calls its [block] with a [receiver], removing the need to create
|
|
||||||
* an additional capturing lambda to invoke it with a receiver object. This can be used to
|
|
||||||
* expose a mutable scope to the provided [block] while leaving the rest of the state object
|
|
||||||
* read-only. For example:
|
|
||||||
*
|
|
||||||
* @param receiver the receiver `this` that [block] will be called with
|
|
||||||
* @param priority the priority of this mutation; [MutatePriority.Default] by default.
|
|
||||||
* Higher priority mutations will interrupt lower priority mutations.
|
|
||||||
* @param block mutation code to run mutually exclusive with any other call to [mutate],
|
|
||||||
* [mutateWith] or [tryMutate].
|
|
||||||
*/
|
|
||||||
suspend fun <T, R> mutateWith(
|
|
||||||
receiver: T,
|
|
||||||
priority: MutatePriority = MutatePriority.Default,
|
|
||||||
block: suspend T.() -> R
|
|
||||||
) = coroutineScope {
|
|
||||||
val mutator = Mutator(priority, coroutineContext[Job]!!)
|
|
||||||
|
|
||||||
tryMutateOrCancel(mutator)
|
|
||||||
|
|
||||||
mutex.withLock {
|
|
||||||
try {
|
|
||||||
receiver.block()
|
|
||||||
} finally {
|
|
||||||
currentMutator.compareAndSet(mutator, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to mutate synchronously if there is no other active caller.
|
|
||||||
* If there is no other active caller, the [block] will be executed in a lock. If there is
|
|
||||||
* another active caller, this method will return false, indicating that the active caller
|
|
||||||
* needs to be cancelled through a [mutate] or [mutateWith] call with an equal or higher
|
|
||||||
* mutation priority.
|
|
||||||
*
|
|
||||||
* Calls to [mutate] and [mutateWith] will suspend until execution of the [block] has finished.
|
|
||||||
*
|
|
||||||
* @param block mutation code to run mutually exclusive with any other call to [mutate],
|
|
||||||
* [mutateWith] or [tryMutate].
|
|
||||||
* @return true if the [block] was executed, false if there was another active caller and the
|
|
||||||
* [block] was not executed.
|
|
||||||
*/
|
|
||||||
fun tryMutate(block: () -> Unit): Boolean {
|
|
||||||
val didLock = mutex.tryLock()
|
|
||||||
if (didLock) {
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
mutex.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return didLock
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,663 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 android.content.Context
|
|
||||||
import android.graphics.PixelFormat
|
|
||||||
import android.os.Build
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewTreeObserver
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.window.OnBackInvokedCallback
|
|
||||||
import android.window.OnBackInvokedDispatcher
|
|
||||||
import androidx.annotation.DoNotInline
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.core.TweenSpec
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.gestures.draggable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.contentColorFor
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionContext
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCompositionContext
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
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.Shape
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.AbstractComposeView
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.platform.ViewRootForInspector
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.semantics.popup
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
|
||||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
|
||||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
|
||||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
|
||||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.tasks.compose.drawer.SheetValue.Expanded
|
|
||||||
import org.tasks.compose.drawer.SheetValue.Hidden
|
|
||||||
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
|
|
||||||
*
|
|
||||||
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
|
|
||||||
* especially when offering a long list of action items, or when items require longer descriptions
|
|
||||||
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
|
|
||||||
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
|
|
||||||
* required action has been taken.
|
|
||||||
*
|
|
||||||
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
|
|
||||||
*
|
|
||||||
* A simple example of a modal bottom sheet looks like this:
|
|
||||||
*
|
|
||||||
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
|
|
||||||
*
|
|
||||||
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
|
|
||||||
* animates to [Hidden].
|
|
||||||
* @param modifier Optional [Modifier] for the bottom sheet.
|
|
||||||
* @param sheetState The state of the bottom sheet.
|
|
||||||
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
|
|
||||||
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
|
|
||||||
* @param shape The shape of the bottom sheet.
|
|
||||||
* @param containerColor The color used for the background of this bottom sheet
|
|
||||||
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
|
|
||||||
* the matching content color for [containerColor], or to the current [LocalContentColor] if
|
|
||||||
* [containerColor] is not a color from the theme.
|
|
||||||
* @param tonalElevation The tonal elevation of this bottom sheet.
|
|
||||||
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
|
|
||||||
* @param dragHandle Optional visual marker to swipe the bottom sheet.
|
|
||||||
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
|
|
||||||
* params.
|
|
||||||
* @param properties [ModalBottomSheetProperties] for further customization of this
|
|
||||||
* modal bottom sheet's behavior.
|
|
||||||
* @param content The content to be displayed inside the bottom sheet.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
fun ModalBottomSheet(
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
|
||||||
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
|
|
||||||
shape: Shape = BottomSheetDefaults.ExpandedShape,
|
|
||||||
containerColor: Color = BottomSheetDefaults.ContainerColor,
|
|
||||||
contentColor: Color = contentColorFor(containerColor),
|
|
||||||
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
|
||||||
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
|
||||||
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
|
||||||
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
|
|
||||||
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties(),
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
// b/291735717 Remove this once deprecated methods without density are removed
|
|
||||||
val density = LocalDensity.current
|
|
||||||
SideEffect {
|
|
||||||
sheetState.density = density
|
|
||||||
}
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val animateToDismiss: () -> Unit = {
|
|
||||||
if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) {
|
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
|
||||||
if (!sheetState.isVisible) {
|
|
||||||
onDismissRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val settleToDismiss: (velocity: Float) -> Unit = {
|
|
||||||
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
|
|
||||||
if (!sheetState.isVisible) onDismissRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalBottomSheetPopup(
|
|
||||||
properties = properties,
|
|
||||||
onDismissRequest = {
|
|
||||||
// if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) {
|
|
||||||
// scope.launch { sheetState.partialExpand() }
|
|
||||||
// } else { // Is expanded without collapsed state or is collapsed.
|
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
windowInsets = windowInsets,
|
|
||||||
) {
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
|
||||||
val fullHeight = constraints.maxHeight
|
|
||||||
Scrim(
|
|
||||||
color = scrimColor,
|
|
||||||
onDismissRequest = animateToDismiss,
|
|
||||||
visible = sheetState.targetValue != Hidden
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.widthIn(max = sheetMaxWidth)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset {
|
|
||||||
IntOffset(
|
|
||||||
0,
|
|
||||||
sheetState
|
|
||||||
.requireOffset()
|
|
||||||
.toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.nestedScroll(
|
|
||||||
remember(sheetState) {
|
|
||||||
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
|
||||||
sheetState = sheetState,
|
|
||||||
orientation = Orientation.Vertical,
|
|
||||||
onFling = settleToDismiss
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.draggable(
|
|
||||||
state = sheetState.anchoredDraggableState.draggableState,
|
|
||||||
orientation = Orientation.Vertical,
|
|
||||||
enabled = sheetState.isVisible,
|
|
||||||
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
|
|
||||||
onDragStopped = { settleToDismiss(it) }
|
|
||||||
)
|
|
||||||
.modalBottomSheetAnchors(
|
|
||||||
sheetState = sheetState,
|
|
||||||
fullHeight = fullHeight.toFloat()
|
|
||||||
),
|
|
||||||
shape = shape,
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
tonalElevation = tonalElevation,
|
|
||||||
) {
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
|
||||||
if (dragHandle != null) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.CenterHorizontally)
|
|
||||||
) {
|
|
||||||
dragHandle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sheetState.hasExpandedState) {
|
|
||||||
LaunchedEffect(sheetState) {
|
|
||||||
sheetState.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Properties used to customize the behavior of a [ModalBottomSheet].
|
|
||||||
*
|
|
||||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
|
||||||
* sheet's window.
|
|
||||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
|
||||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
|
||||||
* the back button is pressed.
|
|
||||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
|
||||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
|
||||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
|
||||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
|
||||||
*/
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
class ModalBottomSheetProperties(
|
|
||||||
val securePolicy: SecureFlagPolicy,
|
|
||||||
val isFocusable: Boolean,
|
|
||||||
val shouldDismissOnBackPress: Boolean
|
|
||||||
) {
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other !is ModalBottomSheetProperties) return false
|
|
||||||
|
|
||||||
if (securePolicy != other.securePolicy) return false
|
|
||||||
if (isFocusable != other.isFocusable) return false
|
|
||||||
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = securePolicy.hashCode()
|
|
||||||
result = 31 * result + isFocusable.hashCode()
|
|
||||||
result = 31 * result + shouldDismissOnBackPress.hashCode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default values for [ModalBottomSheet]
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
object ModalBottomSheetDefaults {
|
|
||||||
/**
|
|
||||||
* Properties used to customize the behavior of a [ModalBottomSheet].
|
|
||||||
*
|
|
||||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
|
||||||
* sheet's window.
|
|
||||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
|
||||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
|
||||||
* the back button is pressed.
|
|
||||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
|
||||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
|
||||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
|
||||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
|
||||||
*/
|
|
||||||
fun properties(
|
|
||||||
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
|
|
||||||
isFocusable: Boolean = true,
|
|
||||||
shouldDismissOnBackPress: Boolean = true
|
|
||||||
) = ModalBottomSheetProperties(securePolicy, isFocusable, shouldDismissOnBackPress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and [remember] a [SheetState] for [ModalBottomSheet].
|
|
||||||
*
|
|
||||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
|
|
||||||
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
|
|
||||||
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
|
|
||||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
fun rememberModalBottomSheetState(
|
|
||||||
skipPartiallyExpanded: Boolean = false,
|
|
||||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
|
||||||
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, Hidden)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Scrim(
|
|
||||||
color: Color,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
visible: Boolean
|
|
||||||
) {
|
|
||||||
if (color.isSpecified) {
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (visible) 1f else 0f,
|
|
||||||
animationSpec = TweenSpec()
|
|
||||||
)
|
|
||||||
val dismissSheet = if (visible) {
|
|
||||||
Modifier
|
|
||||||
.pointerInput(onDismissRequest) {
|
|
||||||
detectTapGestures {
|
|
||||||
onDismissRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clearAndSetSemantics {}
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
Canvas(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.then(dismissSheet)
|
|
||||||
) {
|
|
||||||
drawRect(color = color, alpha = alpha)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
private fun Modifier.modalBottomSheetAnchors(
|
|
||||||
sheetState: SheetState,
|
|
||||||
fullHeight: Float
|
|
||||||
) = onSizeChanged { sheetSize ->
|
|
||||||
|
|
||||||
val newAnchors = DraggableAnchors {
|
|
||||||
Hidden at fullHeight
|
|
||||||
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
|
|
||||||
PartiallyExpanded at fullHeight / 2f
|
|
||||||
}
|
|
||||||
if (sheetSize.height != 0) {
|
|
||||||
Expanded at max(0f, fullHeight - sheetSize.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
|
|
||||||
Hidden -> Hidden
|
|
||||||
PartiallyExpanded, Expanded -> {
|
|
||||||
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded)
|
|
||||||
val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded
|
|
||||||
else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
|
|
||||||
newTarget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Popup specific for modal bottom sheet.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun ModalBottomSheetPopup(
|
|
||||||
properties: ModalBottomSheetProperties,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
windowInsets: WindowInsets,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val view = LocalView.current
|
|
||||||
val id = rememberSaveable { UUID.randomUUID() }
|
|
||||||
val parentComposition = rememberCompositionContext()
|
|
||||||
val currentContent by rememberUpdatedState(content)
|
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
|
||||||
val modalBottomSheetWindow = remember {
|
|
||||||
ModalBottomSheetWindow(
|
|
||||||
properties = properties,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
composeView = view,
|
|
||||||
saveId = id
|
|
||||||
).apply {
|
|
||||||
setCustomContent(
|
|
||||||
parent = parentComposition,
|
|
||||||
content = {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.semantics { this.popup() }
|
|
||||||
.windowInsetsPadding(windowInsets)
|
|
||||||
.then(
|
|
||||||
// TODO(b/290893168): Figure out a solution for APIs < 30.
|
|
||||||
if (Build.VERSION.SDK_INT >= 33)
|
|
||||||
Modifier.imePadding()
|
|
||||||
else Modifier
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
currentContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(modalBottomSheetWindow) {
|
|
||||||
modalBottomSheetWindow.show()
|
|
||||||
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
|
|
||||||
onDispose {
|
|
||||||
modalBottomSheetWindow.disposeComposition()
|
|
||||||
modalBottomSheetWindow.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Custom compose view for [ModalBottomSheet] */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
private class ModalBottomSheetWindow(
|
|
||||||
private val properties: ModalBottomSheetProperties,
|
|
||||||
private var onDismissRequest: () -> Unit,
|
|
||||||
private val composeView: View,
|
|
||||||
saveId: UUID
|
|
||||||
) :
|
|
||||||
AbstractComposeView(composeView.context),
|
|
||||||
ViewTreeObserver.OnGlobalLayoutListener,
|
|
||||||
ViewRootForInspector {
|
|
||||||
|
|
||||||
private var backCallback: Any? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
id = android.R.id.content
|
|
||||||
// Set up view owners
|
|
||||||
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
|
|
||||||
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
|
|
||||||
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
|
|
||||||
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
|
|
||||||
// Enable children to draw their shadow by not clipping them
|
|
||||||
clipChildren = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private val windowManager =
|
|
||||||
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
||||||
|
|
||||||
private val displayWidth: Int
|
|
||||||
get() = context.resources.displayMetrics.widthPixels
|
|
||||||
|
|
||||||
private val params: WindowManager.LayoutParams =
|
|
||||||
WindowManager.LayoutParams().apply {
|
|
||||||
// Position bottom sheet from the bottom of the screen
|
|
||||||
gravity = Gravity.BOTTOM or Gravity.START
|
|
||||||
// Application panel window
|
|
||||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
|
|
||||||
// Fill up the entire app view
|
|
||||||
width = displayWidth
|
|
||||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
|
||||||
|
|
||||||
// Format of screen pixels
|
|
||||||
format = PixelFormat.TRANSLUCENT
|
|
||||||
// Title used as fallback for a11y services
|
|
||||||
// TODO: Provide bottom sheet window resource
|
|
||||||
title = composeView.context.resources.getString(
|
|
||||||
androidx.compose.ui.R.string.default_popup_window_title
|
|
||||||
)
|
|
||||||
// Get the Window token from the parent view
|
|
||||||
token = composeView.applicationWindowToken
|
|
||||||
|
|
||||||
// Flags specific to modal bottom sheet.
|
|
||||||
flags = flags and (
|
|
||||||
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
|
|
||||||
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
|
||||||
).inv()
|
|
||||||
|
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
|
||||||
|
|
||||||
// Security flag
|
|
||||||
val secureFlagEnabled =
|
|
||||||
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
|
|
||||||
if (secureFlagEnabled) {
|
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
|
|
||||||
} else {
|
|
||||||
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focusable
|
|
||||||
if (!properties.isFocusable) {
|
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
|
||||||
} else {
|
|
||||||
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var content: @Composable () -> Unit by mutableStateOf({})
|
|
||||||
|
|
||||||
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCustomContent(
|
|
||||||
parent: CompositionContext? = null,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
parent?.let { setParentCompositionContext(it) }
|
|
||||||
this.content = content
|
|
||||||
shouldCreateCompositionOnAttachedToWindow = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun show() {
|
|
||||||
windowManager.addView(this, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss() {
|
|
||||||
setViewTreeLifecycleOwner(null)
|
|
||||||
setViewTreeSavedStateRegistryOwner(null)
|
|
||||||
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
|
||||||
windowManager.removeViewImmediate(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
|
|
||||||
*/
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
|
||||||
if (event.keyCode == KeyEvent.KEYCODE_BACK && properties.shouldDismissOnBackPress) {
|
|
||||||
if (keyDispatcherState == null) {
|
|
||||||
return super.dispatchKeyEvent(event)
|
|
||||||
}
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
|
||||||
val state = keyDispatcherState
|
|
||||||
state?.startTracking(event, this)
|
|
||||||
return true
|
|
||||||
} else if (event.action == KeyEvent.ACTION_UP) {
|
|
||||||
val state = keyDispatcherState
|
|
||||||
if (state != null && state.isTracking(event) && !event.isCanceled) {
|
|
||||||
onDismissRequest()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.dispatchKeyEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
|
||||||
super.onAttachedToWindow()
|
|
||||||
|
|
||||||
maybeRegisterBackCallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
|
||||||
super.onDetachedFromWindow()
|
|
||||||
|
|
||||||
maybeUnregisterBackCallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeRegisterBackCallback() {
|
|
||||||
if (!properties.shouldDismissOnBackPress || Build.VERSION.SDK_INT < 33) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (backCallback == null) {
|
|
||||||
backCallback = Api33Impl.createBackCallback(onDismissRequest)
|
|
||||||
}
|
|
||||||
Api33Impl.maybeRegisterBackCallback(this, backCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeUnregisterBackCallback() {
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
Api33Impl.maybeUnregisterBackCallback(this, backCallback)
|
|
||||||
}
|
|
||||||
backCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGlobalLayout() {
|
|
||||||
// No-op
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setLayoutDirection(layoutDirection: Int) {
|
|
||||||
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
|
|
||||||
// from the context's locale, but we have one already from the parent composition.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets the "real" layout direction for our content that we obtain from the parent composition.
|
|
||||||
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
|
|
||||||
val direction = when (layoutDirection) {
|
|
||||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
|
||||||
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
|
|
||||||
}
|
|
||||||
super.setLayoutDirection(direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(33)
|
|
||||||
private object Api33Impl {
|
|
||||||
@JvmStatic
|
|
||||||
@DoNotInline
|
|
||||||
fun createBackCallback(onDismissRequest: () -> Unit) =
|
|
||||||
OnBackInvokedCallback(onDismissRequest)
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@DoNotInline
|
|
||||||
fun maybeRegisterBackCallback(view: View, backCallback: Any?) {
|
|
||||||
if (backCallback is OnBackInvokedCallback) {
|
|
||||||
view.findOnBackInvokedDispatcher()?.registerOnBackInvokedCallback(
|
|
||||||
OnBackInvokedDispatcher.PRIORITY_OVERLAY,
|
|
||||||
backCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@DoNotInline
|
|
||||||
fun maybeUnregisterBackCallback(view: View, backCallback: Any?) {
|
|
||||||
if (backCallback is OnBackInvokedCallback) {
|
|
||||||
view.findOnBackInvokedDispatcher()?.unregisterOnBackInvokedCallback(backCallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taken from AndroidPopup.android.kt
|
|
||||||
private fun View.isFlagSecureEnabled(): Boolean {
|
|
||||||
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
|
|
||||||
if (windowParams != null) {
|
|
||||||
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taken from AndroidPopup.android.kt
|
|
||||||
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
|
|
||||||
return when (this) {
|
|
||||||
SecureFlagPolicy.SecureOff -> false
|
|
||||||
SecureFlagPolicy.SecureOn -> true
|
|
||||||
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,502 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
@ -1,127 +0,0 @@
|
|||||||
package org.tasks.compose.drawer
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.tasks.R
|
|
||||||
import org.tasks.Tasks
|
|
||||||
import org.tasks.billing.PurchaseActivity
|
|
||||||
import org.tasks.extensions.Context.findActivity
|
|
||||||
import org.tasks.extensions.Context.openUri
|
|
||||||
import org.tasks.filters.Filter
|
|
||||||
import org.tasks.filters.NavigationDrawerSubheader
|
|
||||||
import org.tasks.preferences.HelpAndFeedback
|
|
||||||
import org.tasks.preferences.MainPreferences
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun TasksMenu(
|
|
||||||
items: ImmutableList<DrawerItem>,
|
|
||||||
isTopAppBar: Boolean,
|
|
||||||
begForMoney: Boolean,
|
|
||||||
setFilter: (Filter) -> Unit,
|
|
||||||
toggleCollapsed: (NavigationDrawerSubheader) -> Unit,
|
|
||||||
addFilter: (NavigationDrawerSubheader) -> Unit,
|
|
||||||
dismiss: () -> Unit,
|
|
||||||
query: String,
|
|
||||||
onQueryChange: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
val skipPartiallyExpanded = remember(expanded) {
|
|
||||||
expanded || isTopAppBar
|
|
||||||
}
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val sheetState = rememberSaveable(
|
|
||||||
skipPartiallyExpanded,
|
|
||||||
saver = SheetState.Saver(
|
|
||||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
|
||||||
confirmValueChange = { true },
|
|
||||||
density = density,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
SheetState(
|
|
||||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
|
||||||
initialValue = if (skipPartiallyExpanded) SheetValue.Expanded else SheetValue.PartiallyExpanded,
|
|
||||||
confirmValueChange = { true },
|
|
||||||
skipHiddenState = false,
|
|
||||||
density = density,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LaunchedEffect(sheetState.currentValue) {
|
|
||||||
if (sheetState.currentValue == SheetValue.Expanded) {
|
|
||||||
expanded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
|
||||||
val settingsRequest = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
context.findActivity()?.recreate()
|
|
||||||
}
|
|
||||||
ModalBottomSheet(
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
onDismissRequest = { dismiss() }
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
TaskListDrawer(
|
|
||||||
begForMoney = begForMoney,
|
|
||||||
filters = items,
|
|
||||||
onClick = {
|
|
||||||
when (it) {
|
|
||||||
is DrawerItem.Filter -> {
|
|
||||||
setFilter(it.type())
|
|
||||||
scope.launch(Dispatchers.Default) {
|
|
||||||
sheetState.hide()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is DrawerItem.Header -> {
|
|
||||||
toggleCollapsed(it.type())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onAddClick = {
|
|
||||||
scope.launch(Dispatchers.Default) {
|
|
||||||
sheetState.hide()
|
|
||||||
dismiss()
|
|
||||||
addFilter(it.type())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDrawerAction = {
|
|
||||||
dismiss()
|
|
||||||
when (it) {
|
|
||||||
DrawerAction.PURCHASE ->
|
|
||||||
if (Tasks.IS_GENERIC)
|
|
||||||
context.openUri(R.string.url_donate)
|
|
||||||
else
|
|
||||||
context.startActivity(Intent(context, PurchaseActivity::class.java))
|
|
||||||
|
|
||||||
DrawerAction.SETTINGS ->
|
|
||||||
settingsRequest.launch(Intent(context, MainPreferences::class.java))
|
|
||||||
|
|
||||||
DrawerAction.HELP_AND_FEEDBACK ->
|
|
||||||
context.startActivity(Intent(context, HelpAndFeedback::class.java))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onErrorClick = {
|
|
||||||
context.startActivity(Intent(context, MainPreferences::class.java))
|
|
||||||
},
|
|
||||||
query = query,
|
|
||||||
onQueryChange = onQueryChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue