mirror of https://github.com/tasks/tasks
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
693 lines
28 KiB
Kotlin
693 lines
28 KiB
Kotlin
/*
|
|
* 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 <T> Modifier.swipeableV2(
|
|
state: SwipeableV2State<T>,
|
|
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 <T> Modifier.swipeAnchors(
|
|
state: SwipeableV2State<T>,
|
|
possibleValues: Set<T>,
|
|
anchorChangeHandler: AnchorChangeHandler<T>? = null,
|
|
calculateAnchor: (value: T, layoutSize: IntSize) -> Float?,
|
|
) = this.then(SwipeAnchorsModifier(
|
|
onDensityChanged = { state.density = it },
|
|
onSizeChanged = { layoutSize ->
|
|
val previousAnchors = state.anchors
|
|
val newAnchors = mutableMapOf<T, Float>()
|
|
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<T>(
|
|
initialValue: T,
|
|
internal val animationSpec: AnimationSpec<Float> = 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<T, Float>())
|
|
|
|
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<T, Float>): 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 <T : Any> Saver(
|
|
animationSpec: AnimationSpec<Float>,
|
|
confirmValueChange: (T) -> Boolean,
|
|
positionalThreshold: Density.(distance: Float) -> Float,
|
|
velocityThreshold: Dp
|
|
) = Saver<SwipeableV2State<T>, 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 <T : Any> rememberSwipeableV2State(
|
|
initialValue: T,
|
|
animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
|
|
confirmValueChange: (newValue: T) -> Boolean = { true }
|
|
): SwipeableV2State<T> {
|
|
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<Float>()
|
|
|
|
/**
|
|
* 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 <T> ReconcileAnimationOnAnchorChangeHandler(
|
|
state: SwipeableV2State<T>,
|
|
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<T> {
|
|
|
|
/**
|
|
* 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<T, Float>,
|
|
newAnchors: Map<T, Float>
|
|
)
|
|
}
|
|
|
|
@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 <T> Map<T, Float>.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 <T> Map<T, Float>.minOrNull() = minOfOrNull { (_, offset) -> offset }
|
|
private fun <T> Map<T, Float>.maxOrNull() = maxOfOrNull { (_, offset) -> offset }
|