From e92ab7f7e1978edd92f42edeac1ff79fc87af117 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sun, 24 Mar 2024 18:30:17 -0500 Subject: [PATCH] Update to latest ModalBottomSheet --- .../tasks/compose/drawer/AnchoredDraggable.kt | 791 ++++++++++++++++++ .../tasks/compose/drawer/ModalBottomSheet.kt | 343 +++++--- .../org/tasks/compose/drawer/SheetState.kt | 153 +++- .../org/tasks/compose/drawer/SwipeableV2.kt | 692 --------------- 4 files changed, 1141 insertions(+), 838 deletions(-) create mode 100644 app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt diff --git a/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt b/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt new file mode 100644 index 000000000..10a918f0c --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt @@ -0,0 +1,791 @@ +/* + * 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 { + + /** + * 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 { + + internal val anchors = mutableMapOf() + + /** + * 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 DraggableAnchors( + builder: DraggableAnchorsConfig.() -> Unit +): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().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 Modifier.anchoredDraggable( + state: AnchoredDraggableState, + 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( + initialValue: T, + internal val positionalThreshold: (totalDistance: Float) -> Float, + internal val velocityThreshold: () -> Float, + val animationSpec: AnimationSpec, + 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, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, + animationSpec: AnimationSpec, + 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 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. + * + * If your anchors depend on the size of the layout, updateAnchors should be called in the + * layout (placement) phase, e.g. through Modifier.onSizeChanged. 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, + 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 not 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. + * + * If the [anchors] change while the [block] is being executed, it will be cancelled and + * re-executed with the latest anchors and target. 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) -> 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. + * + * 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. 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, 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 Saver( + animationSpec: AnimationSpec, + confirmValueChange: (T) -> Boolean, + positionalThreshold: (distance: Float) -> Float, + velocityThreshold: () -> Float, + ) = Saver, 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 AnchoredDraggableState.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 AnchoredDraggableState.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() +} + +private class AnchoredDragFinishedSignal : CancellationException() { + override fun fillInStackTrace(): Throwable { + stackTrace = emptyArray() + return this + } +} + +private suspend fun 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 emptyDraggableAnchors() = MapDraggableAnchors(emptyMap()) + +@OptIn(ExperimentalMaterial3Api::class) +private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { + + 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)" +} diff --git a/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt b/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt index 64822747c..ec771a10e 100644 --- a/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt +++ b/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt @@ -18,11 +18,16 @@ 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 @@ -47,7 +52,9 @@ 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 @@ -63,7 +70,10 @@ 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 @@ -71,20 +81,20 @@ 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.CoroutineScope 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 -import kotlin.math.roundToInt /** * Material Design modal bottom sheet. @@ -105,6 +115,8 @@ import kotlin.math.roundToInt * 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 @@ -115,6 +127,8 @@ import kotlin.math.roundToInt * @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 @@ -123,6 +137,7 @@ 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), @@ -130,11 +145,17 @@ fun ModalBottomSheet( 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.swipeableState.confirmValueChange(Hidden)) { + if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) { scope.launch { sheetState.hide() }.invokeOnCompletion { if (!sheetState.isVisible) { onDismissRequest() @@ -148,23 +169,8 @@ fun ModalBottomSheet( } } - // Callback that is invoked when the anchors have changed. - val anchorChangeHandler = remember(sheetState, scope) { - ModalBottomSheetAnchorChangeHandler( - state = sheetState, - animateTo = { target, velocity -> - scope.launch { sheetState.animateTo(target, velocity = velocity) } - }, - snapTo = { target -> - val didSnapImmediately = sheetState.trySnapTo(target) - if (!didSnapImmediately) { - scope.launch { sheetState.snapTo(target) } - } - } - ) - } - ModalBottomSheetPopup( + properties = properties, onDismissRequest = { // if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) { // scope.launch { sheetState.partialExpand() } @@ -183,7 +189,7 @@ fun ModalBottomSheet( ) Surface( modifier = modifier - .widthIn(max = BottomSheetMaxWidth) + .widthIn(max = sheetMaxWidth) .fillMaxWidth() .align(Alignment.TopCenter) .offset { @@ -203,13 +209,16 @@ fun ModalBottomSheet( ) } ) - .modalBottomSheetSwipeable( + .draggable( + state = sheetState.anchoredDraggableState.draggableState, + orientation = Orientation.Vertical, + enabled = sheetState.isVisible, + startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning, + onDragStopped = { settleToDismiss(it) } + ) + .modalBottomSheetAnchors( sheetState = sheetState, - anchorChangeHandler = anchorChangeHandler, - screenHeight = fullHeight.toFloat(), - onDragStopped = { - settleToDismiss(it) - }, + fullHeight = fullHeight.toFloat() ), shape = shape, color = containerColor, @@ -237,35 +246,69 @@ fun ModalBottomSheet( } } -@Deprecated( - message = "Use ModalBottomSheet overload with windowInset parameter.", - level = DeprecationLevel.HIDDEN -) -@Composable +/** + * 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 -fun ModalBottomSheet( - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), - 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() }, - content: @Composable ColumnScope.() -> Unit, -) = ModalBottomSheet( - onDismissRequest = onDismissRequest, - modifier = modifier, - sheetState = sheetState, - shape = shape, - containerColor = containerColor, - contentColor = contentColor, - tonalElevation = tonalElevation, - scrimColor = scrimColor, - dragHandle = dragHandle, - content = content, -) +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]. @@ -315,69 +358,41 @@ private fun Scrim( } @ExperimentalMaterial3Api -private fun Modifier.modalBottomSheetSwipeable( +private fun Modifier.modalBottomSheetAnchors( sheetState: SheetState, - anchorChangeHandler: AnchorChangeHandler, - screenHeight: Float, - onDragStopped: CoroutineScope.(velocity: Float) -> Unit, -) = draggable( - state = sheetState.swipeableState.swipeDraggableState, - orientation = Orientation.Vertical, - enabled = sheetState.isVisible, - startDragImmediately = sheetState.swipeableState.isAnimationRunning, - onDragStopped = onDragStopped -) - .swipeAnchors( - state = sheetState.swipeableState, - anchorChangeHandler = anchorChangeHandler, - possibleValues = setOf(Hidden, PartiallyExpanded, Expanded), - ) { value, sheetSize -> - when (value) { - Hidden -> screenHeight - PartiallyExpanded -> when { - sheetSize.height < screenHeight / 2 -> null - sheetState.skipPartiallyExpanded -> null - else -> screenHeight / 2f - } - Expanded -> if (sheetSize.height != 0) { - max(0f, screenHeight - sheetSize.height) - } else null + 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) } } -@ExperimentalMaterial3Api -private fun ModalBottomSheetAnchorChangeHandler( - state: SheetState, - animateTo: (target: SheetValue, velocity: Float) -> Unit, - snapTo: (target: SheetValue) -> Unit, -) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors -> - val previousTargetOffset = previousAnchors[previousTarget] - val newTarget = when (previousTarget) { + val newTarget = when (sheetState.anchoredDraggableState.targetValue) { Hidden -> Hidden PartiallyExpanded, Expanded -> { - val hasPartiallyExpandedState = newAnchors.containsKey(PartiallyExpanded) + val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded) val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded - else if (newAnchors.containsKey(Expanded)) Expanded else Hidden + else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden newTarget } } - val newTargetOffset = newAnchors.getValue(newTarget) - if (newTargetOffset != previousTargetOffset) { - if (state.swipeableState.isAnimationRunning || previousAnchors.isEmpty()) { - // Re-target the animation to the new offset if it changed - animateTo(newTarget, state.swipeableState.lastVelocity) - } else { - // Snap to the new offset value of the target if no animation was running - snapTo(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, @@ -386,8 +401,10 @@ internal fun ModalBottomSheetPopup( 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 @@ -399,7 +416,12 @@ internal fun ModalBottomSheetPopup( Modifier .semantics { this.popup() } .windowInsetsPadding(windowInsets) - .imePadding() + .then( + // TODO(b/290893168): Figure out a solution for APIs < 30. + if (Build.VERSION.SDK_INT >= 33) + Modifier.imePadding() + else Modifier + ) ) { currentContent() } @@ -410,6 +432,7 @@ internal fun ModalBottomSheetPopup( DisposableEffect(modalBottomSheetWindow) { modalBottomSheetWindow.show() + modalBottomSheetWindow.superSetLayoutDirection(layoutDirection) onDispose { modalBottomSheetWindow.disposeComposition() modalBottomSheetWindow.dismiss() @@ -418,14 +441,19 @@ internal fun ModalBottomSheetPopup( } /** 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, + saveId: UUID ) : AbstractComposeView(composeView.context), ViewTreeObserver.OnGlobalLayoutListener, ViewRootForInspector { + + private var backCallback: Any? = null + init { id = android.R.id.content // Set up view owners @@ -441,10 +469,7 @@ private class ModalBottomSheetWindow( composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager private val displayWidth: Int - get() { - val density = context.resources.displayMetrics.density - return (context.resources.configuration.screenWidthDp * density).roundToInt() - } + get() = context.resources.displayMetrics.widthPixels private val params: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { @@ -473,6 +498,22 @@ private class ModalBottomSheetWindow( ).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({}) @@ -509,7 +550,7 @@ private class ModalBottomSheetWindow( * Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.keyCode == KeyEvent.KEYCODE_BACK && properties.shouldDismissOnBackPress) { if (keyDispatcherState == null) { return super.dispatchKeyEvent(event) } @@ -528,7 +569,95 @@ private class ModalBottomSheetWindow( 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 + } } diff --git a/app/src/main/java/org/tasks/compose/drawer/SheetState.kt b/app/src/main/java/org/tasks/compose/drawer/SheetState.kt index 80c16d81a..ffb493a22 100644 --- a/app/src/main/java/org/tasks/compose/drawer/SheetState.kt +++ b/app/src/main/java/org/tasks/compose/drawer/SheetState.kt @@ -24,7 +24,6 @@ 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.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -37,9 +36,12 @@ 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 @@ -51,7 +53,7 @@ import org.tasks.compose.drawer.SheetValue.PartiallyExpanded /** * State of a sheet composable, such as [ModalBottomSheet] * - * Contains states relating to it's swipe position as well as animations between state values. + * 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 @@ -65,12 +67,48 @@ import org.tasks.compose.drawer.SheetValue.PartiallyExpanded */ @Stable @ExperimentalMaterial3Api -class SheetState( +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) { @@ -93,7 +131,7 @@ class SheetState( * was in before the swipe or animation started. */ - val currentValue: SheetValue get() = swipeableState.currentValue + val currentValue: SheetValue get() = anchoredDraggableState.currentValue /** * The target value of the bottom sheet state. @@ -102,13 +140,13 @@ class SheetState( * 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() = swipeableState.targetValue + val targetValue: SheetValue get() = anchoredDraggableState.targetValue /** * Whether the modal bottom sheet is visible. */ val isVisible: Boolean - get() = swipeableState.currentValue != Hidden + get() = anchoredDraggableState.currentValue != Hidden /** * Require the current offset (in pixels) of the bottom sheet. @@ -126,20 +164,20 @@ class SheetState( * * @throws IllegalStateException If the offset has not been initialized yet */ - fun requireOffset(): Float = swipeableState.requireOffset() + fun requireOffset(): Float = anchoredDraggableState.requireOffset() /** * Whether the sheet has an expanded state defined. */ val hasExpandedState: Boolean - get() = swipeableState.hasAnchorForValue(Expanded) + get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded) /** * Whether the modal bottom sheet has a partially expanded state defined. */ val hasPartiallyExpandedState: Boolean - get() = swipeableState.hasAnchorForValue(PartiallyExpanded) + get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded) /** * Fully expand the bottom sheet with animation and suspend until it is fully expanded or @@ -148,7 +186,7 @@ class SheetState( * @throws [CancellationException] if the animation is interrupted */ suspend fun expand() { - swipeableState.animateTo(Expanded) + anchoredDraggableState.animateTo(Expanded) } /** @@ -203,9 +241,9 @@ class SheetState( */ internal suspend fun animateTo( targetValue: SheetValue, - velocity: Float = swipeableState.lastVelocity + velocity: Float = anchoredDraggableState.lastVelocity ) { - swipeableState.animateTo(targetValue, velocity) + anchoredDraggableState.animateTo(targetValue, velocity) } /** @@ -217,37 +255,58 @@ class SheetState( * @param targetValue The target value of the animation */ internal suspend fun snapTo(targetValue: SheetValue) { - swipeableState.snapTo(targetValue) + anchoredDraggableState.snapTo(targetValue) } - /** - * Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe - * 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 - */ - internal fun trySnapTo(targetValue: SheetValue) = swipeableState.trySnapTo(targetValue) - /** * Find the closest anchor taking into account the velocity and settle at it with an animation. */ internal suspend fun settle(velocity: Float) { - swipeableState.settle(velocity) + anchoredDraggableState.settle(velocity) } - internal var swipeableState = SwipeableV2State( + internal var anchoredDraggableState = AnchoredDraggableState( initialValue = initialValue, - animationSpec = SwipeableV2Defaults.AnimationSpec, + animationSpec = AnchoredDraggableDefaults.AnimationSpec, confirmValueChange = confirmValueChange, + positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } }, + velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } } ) - internal val offset: Float? get() = swipeableState.offset + 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( + 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 @@ -287,10 +346,14 @@ enum class SheetValue { @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.top() + @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 @@ -301,13 +364,20 @@ object BottomSheetDefaults { /** The default color of the scrim overlay for background content. */ val ScrimColor: Color - @Composable get() = MaterialTheme.colorScheme.scrim.copy(.32f) + @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. */ @@ -325,7 +395,7 @@ object BottomSheetDefaults { height: Dp = 4.dp, shape: Shape = MaterialTheme.shapes.extraLarge, color: Color = MaterialTheme.colorScheme.onSurfaceVariant - .copy(.4f), + .copy(alpha = .4f), ) { Surface( modifier = modifier @@ -353,7 +423,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.toFloat() return if (delta < 0 && source == NestedScrollSource.Drag) { - sheetState.swipeableState.dispatchRawDelta(delta).toOffset() + sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset() } else { Offset.Zero } @@ -365,7 +435,7 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( source: NestedScrollSource ): Offset { return if (source == NestedScrollSource.Drag) { - sheetState.swipeableState.dispatchRawDelta(available.toFloat()).toOffset() + sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() } else { Offset.Zero } @@ -374,7 +444,8 @@ internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( override suspend fun onPreFling(available: Velocity): Velocity { val toFling = available.toFloat() val currentOffset = sheetState.requireOffset() - return if (toFling < 0 && currentOffset > sheetState.swipeableState.minOffset) { + 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 @@ -408,20 +479,24 @@ internal fun rememberSheetState( initialValue: SheetValue = Hidden, skipHiddenState: Boolean = false, ): SheetState { + + val density = LocalDensity.current return rememberSaveable( skipPartiallyExpanded, confirmValueChange, saver = SheetState.Saver( skipPartiallyExpanded = skipPartiallyExpanded, - confirmValueChange = confirmValueChange + confirmValueChange = confirmValueChange, + density = density ) ) { - SheetState(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) + SheetState( + skipPartiallyExpanded, + density, + initialValue, + confirmValueChange, + skipHiddenState + ) } } private val DragHandleVerticalPadding = 22.dp -internal val BottomSheetMaxWidth = 640.dp - -internal fun CornerBasedShape.top(): CornerBasedShape { - return copy(bottomStart = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp)) -} diff --git a/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt b/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt deleted file mode 100644 index 76b78a5d8..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt +++ /dev/null @@ -1,692 +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. - */ -// This is a mirror of androidx.compose.material.SwipeableV2.kt from M2. -// DO NOT MODIFY DIRECTLY, make changes upstream and mirror them. - -package org.tasks.compose.drawer - -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.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.LayoutModifier -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.OnRemeasuredModifier -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.InspectorValueInfo -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -/** - * Enable swipe gestures between a set of predefined values. - * - * When a swipe is detected, the offset of the [SwipeableV2State] will be updated with the swipe - * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). - * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is - * reached, the value of the [SwipeableV2State] will also be updated to the value corresponding to - * the new anchor. - * - * Swiping is constrained between the minimum and maximum anchors. - * - * @param state The associated [SwipeableV2State]. - * @param orientation The orientation in which the swipeable can be swiped. - * @param enabled Whether this [swipeableV2] is enabled and should react to the user's input. - * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom - * swipe will behave like bottom to top, and a left to right swipe will behave like right to left. - * @param interactionSource Optional [MutableInteractionSource] that will passed on to - * the internal [Modifier.draggable]. - */ -@ExperimentalMaterial3Api -internal fun Modifier.swipeableV2( - state: SwipeableV2State, - orientation: Orientation, - enabled: Boolean = true, - reverseDirection: Boolean = false, - interactionSource: MutableInteractionSource? = null -) = draggable( - state = state.swipeDraggableState, - orientation = orientation, - enabled = enabled, - interactionSource = interactionSource, - reverseDirection = reverseDirection, - startDragImmediately = state.isAnimationRunning, - onDragStopped = { velocity -> launch { state.settle(velocity) } } -) - -/** - * Define anchor points for a given [SwipeableV2State] based on this node's layout size and update - * the state with them. - * - * @param state The associated [SwipeableV2State] - * @param possibleValues All possible values the [SwipeableV2State] could be in. - * @param anchorChangeHandler A callback to be invoked when the anchors have changed, - * `null` by default. Components with custom reconciliation logic should implement this callback, - * i.e. to re-target an in-progress animation. - * @param calculateAnchor This method will be invoked to calculate the position of all - * [possibleValues], given this node's layout size. Return the anchor's offset from the initial - * anchor, or `null` to indicate that a value does not have an anchor. - */ -@ExperimentalMaterial3Api -internal fun Modifier.swipeAnchors( - state: SwipeableV2State, - possibleValues: Set, - anchorChangeHandler: AnchorChangeHandler? = null, - calculateAnchor: (value: T, layoutSize: IntSize) -> Float?, -) = this.then(SwipeAnchorsModifier( - onDensityChanged = { state.density = it }, - onSizeChanged = { layoutSize -> - val previousAnchors = state.anchors - val newAnchors = mutableMapOf() - possibleValues.forEach { - val anchorValue = calculateAnchor(it, layoutSize) - if (anchorValue != null) { - newAnchors[it] = anchorValue - } - } - if (previousAnchors != newAnchors) { - val previousTarget = state.targetValue - val stateRequiresCleanup = state.updateAnchors(newAnchors) - if (stateRequiresCleanup) { - anchorChangeHandler?.onAnchorsChanged( - previousTarget, - previousAnchors, - newAnchors - ) - } - } - }, - inspectorInfo = debugInspectorInfo { - name = "swipeAnchors" - properties["state"] = state - properties["possibleValues"] = possibleValues - properties["anchorChangeHandler"] = anchorChangeHandler - properties["calculateAnchor"] = calculateAnchor - } -)) - -/** - * State of the [swipeableV2] modifier. - * - * This contains necessary information about any ongoing swipe or animation and provides methods - * to change the state either immediately or by starting an animation. To create and remember a - * [SwipeableV2State] use [rememberSwipeableV2State]. - * - * @param initialValue The initial value of the state. - * @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 to be used when calculating the target state - * while a swipe is in progress and when settling after the swipe 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. See the - * [fractionalPositionalThreshold] and [fixedPositionalThreshold] methods. - * @param velocityThreshold The velocity threshold (in dp 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. - */ -@Stable -@ExperimentalMaterial3Api -internal class SwipeableV2State( - initialValue: T, - internal val animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec, - internal val confirmValueChange: (newValue: T) -> Boolean = { true }, - internal val positionalThreshold: Density.(totalDistance: Float) -> Float = - SwipeableV2Defaults.PositionalThreshold, - internal val velocityThreshold: Dp = SwipeableV2Defaults.VelocityThreshold, -) { - - private val swipeMutex = InternalMutatorMutex() - - internal val swipeDraggableState = object : DraggableState { - private val dragScope = object : DragScope { - override fun dragBy(pixels: Float) { - this@SwipeableV2State.dispatchRawDelta(pixels) - } - } - - override suspend fun drag( - dragPriority: MutatePriority, - block: suspend DragScope.() -> Unit - ) { - swipe(dragPriority) { dragScope.block() } - } - - override fun dispatchRawDelta(delta: Float) { - this@SwipeableV2State.dispatchRawDelta(delta) - } - } - - /** - * The current value of the [SwipeableV2State]. - */ - 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 { - animationTarget ?: run { - val currentOffset = offset - if (currentOffset != null) { - computeTarget(currentOffset, currentValue, velocity = 0f) - } else currentValue - } - } - - /** - * The current offset, or null if it has not been initialized yet. - * - * The offset will be initialized during the first measurement phase of the node that the - * [swipeableV2] modifier is attached to. These are the phases: - * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing - * During the first composition, the offset will be null. 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. - * - * To guarantee stricter semantics, consider using [requireOffset]. - */ - @get:Suppress("AutoBoxing") - var offset: Float? by mutableStateOf(null) - private set - - /** - * Require the current offset. - * - * @throws IllegalStateException If the offset has not been initialized yet - */ - fun requireOffset(): Float = checkNotNull(offset) { - "The offset was read before being initialized. Did you access the offset in a phase " + - "before layout, like effects or composition?" - } - - /** - * Whether an animation is currently in progress. - */ - val isAnimationRunning: Boolean get() = animationTarget != null - - /** - * The fraction of the progress going from [currentValue] to [targetValue], within [0f..1f] - * bounds. - */ - /*@FloatRange(from = 0f, to = 1f)*/ - val progress: Float by derivedStateOf { - val a = anchors[currentValue] ?: 0f - val b = anchors[targetValue] ?: 0f - val distance = abs(b - a) - if (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 mutableStateOf(0f) - private set - - /** - * The minimum offset this state can reach. This will be the smallest anchor, or - * [Float.NEGATIVE_INFINITY] if the anchors are not initialized yet. - */ - val minOffset by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY } - - /** - * The maximum offset this state can reach. This will be the biggest anchor, or - * [Float.POSITIVE_INFINITY] if the anchors are not initialized yet. - */ - val maxOffset by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY } - - private var animationTarget: T? by mutableStateOf(null) - - internal var anchors by mutableStateOf(emptyMap()) - - internal var density: Density? = null - - /** - * Update the anchors. - * If the previous set of anchors was empty, attempt to update the offset to match the initial - * value's anchor. - * - * @return true if the state needs to be adjusted after updating the anchors, e.g. if the - * initial value is not found in the initial set of anchors. false if no further updates are - * needed. - */ - internal fun updateAnchors(newAnchors: Map): Boolean { - val previousAnchorsEmpty = anchors.isEmpty() - anchors = newAnchors - val initialValueHasAnchor = if (previousAnchorsEmpty) { - val initialValue = currentValue - val initialValueAnchor = anchors[initialValue] - val initialValueHasAnchor = initialValueAnchor != null - if (initialValueHasAnchor) trySnapTo(initialValue) - initialValueHasAnchor - } else true - return !initialValueHasAnchor || !previousAnchorsEmpty - } - - /** - * Whether the [value] has an anchor associated with it. - */ - fun hasAnchorForValue(value: T): Boolean = anchors.containsKey(value) - - /** - * Snap to a [targetValue] without any animation. - * 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 - */ - suspend fun snapTo(targetValue: T) { - swipe { snap(targetValue) } - } - - /** - * 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 - * @param velocity The velocity the animation should start with, [lastVelocity] by default - */ - suspend fun animateTo( - targetValue: T, - velocity: Float = lastVelocity, - ) { - val targetOffset = anchors[targetValue] - if (targetOffset != null) { - try { - swipe { - animationTarget = targetValue - var prev = offset ?: 0f - 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. - offset = value - prev = value - lastVelocity = velocity - } - lastVelocity = 0f - } - } finally { - animationTarget = null - val endOffset = requireOffset() - val endState = anchors - .entries - .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f } - ?.key - this.currentValue = endState ?: currentValue - } - } else { - currentValue = targetValue - } - } - - /** - * Find the closest anchor taking into account the velocity and settle at it with an animation. - */ - 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) - } - } - - /** - * Swipe by the [delta], coerce it in the bounds and dispatch it to the [SwipeableV2State]. - * - * @return The delta the consumed by the [SwipeableV2State] - */ - fun dispatchRawDelta(delta: Float): Float { - val currentDragPosition = offset ?: 0f - val potentiallyConsumed = currentDragPosition + delta - val clamped = potentiallyConsumed.coerceIn(minOffset, maxOffset) - val deltaToConsume = clamped - currentDragPosition - if (abs(deltaToConsume) >= 0) { - offset = ((offset ?: 0f) + deltaToConsume).coerceIn(minOffset, maxOffset) - } - return deltaToConsume - } - - private fun computeTarget( - offset: Float, - currentValue: T, - velocity: Float - ): T { - val currentAnchors = anchors - val currentAnchor = currentAnchors[currentValue] - val currentDensity = requireDensity() - val velocityThresholdPx = with(currentDensity) { velocityThreshold.toPx() } - return if (currentAnchor == offset || currentAnchor == null) { - currentValue - } else if (currentAnchor < 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.getValue(upper) - currentAnchor) - val relativeThreshold = abs(positionalThreshold(currentDensity, distance)) - val absoluteThreshold = abs(currentAnchor + 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(currentAnchor - currentAnchors.getValue(lower)) - val relativeThreshold = abs(positionalThreshold(currentDensity, distance)) - val absoluteThreshold = abs(currentAnchor - 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 requireDensity() = requireNotNull(density) { - "SwipeableState did not have a density attached. Are you using Modifier.swipeable with " + - "this=$this SwipeableState?" - } - - private suspend fun swipe( - swipePriority: MutatePriority = MutatePriority.Default, - action: suspend () -> Unit - ): Unit = coroutineScope { swipeMutex.mutate(swipePriority, action) } - - /** - * Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe - * 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 - */ - internal fun trySnapTo(targetValue: T): Boolean = swipeMutex.tryMutate { snap(targetValue) } - - private fun snap(targetValue: T) { - val targetOffset = anchors[targetValue] - if (targetOffset != null) { - dispatchRawDelta(targetOffset - (offset ?: 0f)) - currentValue = targetValue - animationTarget = null - } else { - currentValue = targetValue - } - } - - companion object { - /** - * The default [Saver] implementation for [SwipeableV2State]. - */ - @ExperimentalMaterial3Api - fun Saver( - animationSpec: AnimationSpec, - confirmValueChange: (T) -> Boolean, - positionalThreshold: Density.(distance: Float) -> Float, - velocityThreshold: Dp - ) = Saver, T>( - save = { it.currentValue }, - restore = { - SwipeableV2State( - initialValue = it, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ) - } - ) - } -} - -/** - * Create and remember a [SwipeableV2State]. - * - * @param initialValue The initial value. - * @param animationSpec The default animation that will be used to animate to a new value. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending value change. - */ -@Composable -@ExperimentalMaterial3Api -internal fun rememberSwipeableV2State( - initialValue: T, - animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec, - confirmValueChange: (newValue: T) -> Boolean = { true } -): SwipeableV2State { - return rememberSaveable( - initialValue, animationSpec, confirmValueChange, - saver = SwipeableV2State.Saver( - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = SwipeableV2Defaults.PositionalThreshold, - velocityThreshold = SwipeableV2Defaults.VelocityThreshold - ), - ) { - SwipeableV2State( - initialValue = initialValue, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = SwipeableV2Defaults.PositionalThreshold, - velocityThreshold = SwipeableV2Defaults.VelocityThreshold - ) - } -} - -/** - * Expresses a fixed positional threshold of [threshold] dp. This will be the distance from an - * anchor that needs to be reached for [SwipeableV2State] to settle to the next closest anchor. - * - * @see [fractionalPositionalThreshold] for a fractional positional threshold - */ -@ExperimentalMaterial3Api -internal fun fixedPositionalThreshold(threshold: Dp): Density.(distance: Float) -> Float = { - threshold.toPx() -} - -/** - * Expresses a relative positional threshold of the [fraction] of the distance to the closest anchor - * in the current direction. This will be the distance from an anchor that needs to be reached for - * [SwipeableV2State] to settle to the next closest anchor. - * - * @see [fixedPositionalThreshold] for a fixed positional threshold - */ -@ExperimentalMaterial3Api -internal fun fractionalPositionalThreshold( - fraction: Float -): Density.(distance: Float) -> Float = { distance -> distance * fraction } - -/** - * Contains useful defaults for [swipeableV2] and [SwipeableV2State]. - */ -@Stable -@ExperimentalMaterial3Api -internal object SwipeableV2Defaults { - /** - * The default animation used by [SwipeableV2State]. - */ - @ExperimentalMaterial3Api - val AnimationSpec = SpringSpec() - - /** - * The default velocity threshold (1.8 dp per millisecond) used by [rememberSwipeableV2State]. - */ - @ExperimentalMaterial3Api - val VelocityThreshold: Dp = 125.dp - - /** - * The default positional threshold (56 dp) used by [rememberSwipeableV2State] - */ - @ExperimentalMaterial3Api - val PositionalThreshold: Density.(totalDistance: Float) -> Float = - fixedPositionalThreshold(56.dp) - - /** - * A [AnchorChangeHandler] implementation that attempts to reconcile an in-progress animation - * by re-targeting it if necessary or finding the closest new anchor. - * If the previous anchor is not in the new set of anchors, this implementation will snap to the - * closest anchor. - * - * Consider implementing a custom handler for more complex components like sheets. - * The [animate] and [snap] lambdas hoist the animation and snap logic. Usually these will just - * delegate to [SwipeableV2State]. - * - * @param state The [SwipeableV2State] the change handler will read from - * @param animate A lambda that gets invoked to start an animation to a new target - * @param snap A lambda that gets invoked to snap to a new target - */ - @ExperimentalMaterial3Api - internal fun ReconcileAnimationOnAnchorChangeHandler( - state: SwipeableV2State, - animate: (target: T, velocity: Float) -> Unit, - snap: (target: T) -> Unit - ) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors -> - val previousTargetOffset = previousAnchors[previousTarget] - val newTargetOffset = newAnchors[previousTarget] - if (previousTargetOffset != newTargetOffset) { - if (newTargetOffset != null) { - animate(previousTarget, state.lastVelocity) - } else { - snap(newAnchors.closestAnchor(offset = state.requireOffset())) - } - } - } -} - -/** - * Defines a callback that is invoked when the anchors have changed. - * - * Components with custom reconciliation logic should implement this callback, for example to - * re-target an in-progress animation when the anchors change. - * - * @see SwipeableV2Defaults.ReconcileAnimationOnAnchorChangeHandler for a default implementation - */ -@ExperimentalMaterial3Api -internal fun interface AnchorChangeHandler { - - /** - * Callback that is invoked when the anchors have changed, after the [SwipeableV2State] has been - * updated with them. Use this hook to re-launch animations or interrupt them if needed. - * - * @param previousTargetValue The target value before the anchors were updated - * @param previousAnchors The previously set anchors - * @param newAnchors The newly set anchors - */ - fun onAnchorsChanged( - previousTargetValue: T, - previousAnchors: Map, - newAnchors: Map - ) -} - -@Stable -private class SwipeAnchorsModifier( - private val onDensityChanged: (density: Density) -> Unit, - private val onSizeChanged: (layoutSize: IntSize) -> Unit, - inspectorInfo: InspectorInfo.() -> Unit, -) : LayoutModifier, OnRemeasuredModifier, InspectorValueInfo(inspectorInfo) { - - private var lastDensity: Float = -1f - private var lastFontScale: Float = -1f - - override fun MeasureScope.measure( - measurable: Measurable, - constraints: Constraints - ): MeasureResult { - if (density != lastDensity || fontScale != lastFontScale) { - onDensityChanged(Density(density, fontScale)) - lastDensity = density - lastFontScale = fontScale - } - val placeable = measurable.measure(constraints) - return layout(placeable.width, placeable.height) { placeable.place(0, 0) } - } - - override fun onRemeasured(size: IntSize) { - onSizeChanged(size) - } - - override fun toString() = "SwipeAnchorsModifierImpl(updateDensity=$onDensityChanged, " + - "onSizeChanged=$onSizeChanged)" -} - -private fun Map.closestAnchor( - offset: Float = 0f, - searchUpwards: Boolean = false -): T { - require(isNotEmpty()) { "The anchors were empty when trying to find the closest anchor" } - return minBy { (_, anchor) -> - val delta = if (searchUpwards) anchor - offset else offset - anchor - if (delta < 0) Float.POSITIVE_INFINITY else delta - }.key -} - -private fun Map.minOrNull() = minOfOrNull { (_, offset) -> offset } -private fun Map.maxOrNull() = maxOfOrNull { (_, offset) -> offset }