1 /*
<lambda>null2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.credentialmanager.common.material
18
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.SpringSpec
22 import androidx.compose.animation.core.Spring
23 import androidx.compose.foundation.gestures.DraggableState
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.foundation.gestures.draggable
26 import androidx.compose.foundation.interaction.MutableInteractionSource
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.DisposableEffect
29 import androidx.compose.runtime.Immutable
30 import androidx.compose.runtime.LaunchedEffect
31 import androidx.compose.runtime.Stable
32 import androidx.compose.runtime.State
33 import androidx.compose.runtime.getValue
34 import androidx.compose.runtime.mutableStateOf
35 import androidx.compose.runtime.remember
36 import androidx.compose.runtime.saveable.Saver
37 import androidx.compose.runtime.saveable.rememberSaveable
38 import androidx.compose.runtime.setValue
39 import androidx.compose.runtime.snapshotFlow
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.composed
42 import androidx.compose.ui.geometry.Offset
43 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
44 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
45 import androidx.compose.ui.platform.LocalDensity
46 import androidx.compose.ui.platform.debugInspectorInfo
47 import androidx.compose.ui.unit.Density
48 import androidx.compose.ui.unit.Dp
49 import androidx.compose.ui.unit.Velocity
50 import androidx.compose.ui.unit.dp
51 import androidx.compose.ui.util.lerp
52 import com.android.credentialmanager.common.material.SwipeableDefaults.AnimationSpec
53 import com.android.credentialmanager.common.material.SwipeableDefaults.StandardResistanceFactor
54 import com.android.credentialmanager.common.material.SwipeableDefaults.VelocityThreshold
55 import com.android.credentialmanager.common.material.SwipeableDefaults.resistanceConfig
56 import kotlinx.coroutines.CancellationException
57 import kotlinx.coroutines.flow.Flow
58 import kotlinx.coroutines.flow.collect
59 import kotlinx.coroutines.flow.filter
60 import kotlinx.coroutines.flow.take
61 import kotlinx.coroutines.launch
62 import kotlin.math.PI
63 import kotlin.math.abs
64 import kotlin.math.sign
65 import kotlin.math.sin
66
67 /**
68 * State of the [swipeable] modifier.
69 *
70 * This contains necessary information about any ongoing swipe or animation and provides methods
71 * to change the state either immediately or by starting an animation. To create and remember a
72 * [SwipeableState] with the default animation clock, use [rememberSwipeableState].
73 *
74 * @param initialValue The initial value of the state.
75 * @param animationSpec The default animation that will be used to animate to a new state.
76 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
77 */
78 @Stable
79 open class SwipeableState<T>(
80 initialValue: T,
81 internal val animationSpec: AnimationSpec<Float> = AnimationSpec,
82 internal val confirmStateChange: (newValue: T) -> Boolean = { true }
83 ) {
84 /**
85 * The current value of the state.
86 *
87 * If no swipe or animation is in progress, this corresponds to the anchor at which the
88 * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds
89 * the last anchor at which the [swipeable] was settled before the swipe or animation started.
90 */
91 var currentValue: T by mutableStateOf(initialValue)
92 private set
93
94 /**
95 * Whether the state is currently animating.
96 */
97 var isAnimationRunning: Boolean by mutableStateOf(false)
98 private set
99
100 /**
101 * The current position (in pixels) of the [swipeable].
102 *
103 * You should use this state to offset your content accordingly. The recommended way is to
104 * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled.
105 */
106 val offset: State<Float> get() = offsetState
107
108 /**
109 * The amount by which the [swipeable] has been swiped past its bounds.
110 */
111 val overflow: State<Float> get() = overflowState
112
113 // Use `Float.NaN` as a placeholder while the state is uninitialised.
114 private val offsetState = mutableStateOf(0f)
115 private val overflowState = mutableStateOf(0f)
116
117 // the source of truth for the "real"(non ui) position
118 // basically position in bounds + overflow
119 private val absoluteOffset = mutableStateOf(0f)
120
121 // current animation target, if animating, otherwise null
122 private val animationTarget = mutableStateOf<Float?>(null)
123
124 internal var anchors by mutableStateOf(emptyMap<Float, T>())
125
126 private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
<lambda>null127 snapshotFlow { anchors }
<lambda>null128 .filter { it.isNotEmpty() }
129 .take(1)
130
131 internal var minBound = Float.NEGATIVE_INFINITY
132 internal var maxBound = Float.POSITIVE_INFINITY
133
ensureInitnull134 internal fun ensureInit(newAnchors: Map<Float, T>) {
135 if (anchors.isEmpty()) {
136 // need to do initial synchronization synchronously :(
137 val initialOffset = newAnchors.getOffset(currentValue)
138 requireNotNull(initialOffset) {
139 "The initial value must have an associated anchor."
140 }
141 offsetState.value = initialOffset
142 absoluteOffset.value = initialOffset
143 }
144 }
145
processNewAnchorsnull146 internal suspend fun processNewAnchors(
147 oldAnchors: Map<Float, T>,
148 newAnchors: Map<Float, T>
149 ) {
150 if (oldAnchors.isEmpty()) {
151 // If this is the first time that we receive anchors, then we need to initialise
152 // the state so we snap to the offset associated to the initial value.
153 minBound = newAnchors.keys.minOrNull()!!
154 maxBound = newAnchors.keys.maxOrNull()!!
155 val initialOffset = newAnchors.getOffset(currentValue)
156 requireNotNull(initialOffset) {
157 "The initial value must have an associated anchor."
158 }
159 snapInternalToOffset(initialOffset)
160 } else if (newAnchors != oldAnchors) {
161 // If we have received new anchors, then the offset of the current value might
162 // have changed, so we need to animate to the new offset. If the current value
163 // has been removed from the anchors then we animate to the closest anchor
164 // instead. Note that this stops any ongoing animation.
165 minBound = Float.NEGATIVE_INFINITY
166 maxBound = Float.POSITIVE_INFINITY
167 val animationTargetValue = animationTarget.value
168 // if we're in the animation already, let's find it a new home
169 val targetOffset = if (animationTargetValue != null) {
170 // first, try to map old state to the new state
171 val oldState = oldAnchors[animationTargetValue]
172 val newState = newAnchors.getOffset(oldState)
173 // return new state if exists, or find the closes one among new anchors
174 newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
175 } else {
176 // we're not animating, proceed by finding the new anchors for an old value
177 val actualOldValue = oldAnchors[offset.value]
178 val value = if (actualOldValue == currentValue) currentValue else actualOldValue
179 newAnchors.getOffset(value) ?: newAnchors
180 .keys.minByOrNull { abs(it - offset.value) }!!
181 }
182 try {
183 animateInternalToOffset(targetOffset, animationSpec)
184 } catch (c: CancellationException) {
185 // If the animation was interrupted for any reason, snap as a last resort.
186 snapInternalToOffset(targetOffset)
187 } finally {
188 currentValue = newAnchors.getValue(targetOffset)
189 minBound = newAnchors.keys.minOrNull()!!
190 maxBound = newAnchors.keys.maxOrNull()!!
191 }
192 }
193 }
194
_null195 internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
196
197 internal var velocityThreshold by mutableStateOf(0f)
198
199 internal var resistance: ResistanceConfig? by mutableStateOf(null)
200
<lambda>null201 internal val draggableState = DraggableState {
202 val newAbsolute = absoluteOffset.value + it
203 val clamped = newAbsolute.coerceIn(minBound, maxBound)
204 val overflow = newAbsolute - clamped
205 val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
206 offsetState.value = clamped + resistanceDelta
207 overflowState.value = overflow
208 absoluteOffset.value = newAbsolute
209 }
210
snapInternalToOffsetnull211 private suspend fun snapInternalToOffset(target: Float) {
212 draggableState.drag {
213 dragBy(target - absoluteOffset.value)
214 }
215 }
216
animateInternalToOffsetnull217 private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
218 draggableState.drag {
219 var prevValue = absoluteOffset.value
220 animationTarget.value = target
221 isAnimationRunning = true
222 try {
223 Animatable(prevValue).animateTo(target, spec) {
224 dragBy(this.value - prevValue)
225 prevValue = this.value
226 }
227 } finally {
228 animationTarget.value = null
229 isAnimationRunning = false
230 }
231 }
232 }
233
234 /**
235 * The target value of the state.
236 *
237 * If a swipe is in progress, this is the value that the [swipeable] would animate to if the
238 * swipe finished. If an animation is running, this is the target value of that animation.
239 * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
240 */
241 val targetValue: T
242 get() {
243 // TODO(calintat): Track current velocity (b/149549482) and use that here.
244 val target = animationTarget.value ?: computeTarget(
245 offset = offset.value,
246 lastValue = anchors.getOffset(currentValue) ?: offset.value,
247 anchors = anchors.keys,
248 thresholds = thresholds,
249 velocity = 0f,
250 velocityThreshold = Float.POSITIVE_INFINITY
251 )
252 return anchors[target] ?: currentValue
253 }
254
255 /**
256 * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
257 *
258 * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
259 */
260 val progress: SwipeProgress<T>
261 get() {
262 val bounds = findBounds(offset.value, anchors.keys)
263 val from: T
264 val to: T
265 val fraction: Float
266 when (bounds.size) {
267 0 -> {
268 from = currentValue
269 to = currentValue
270 fraction = 1f
271 }
272 1 -> {
273 from = anchors.getValue(bounds[0])
274 to = anchors.getValue(bounds[0])
275 fraction = 1f
276 }
277 else -> {
278 val (a, b) =
279 if (direction > 0f) {
280 bounds[0] to bounds[1]
281 } else {
282 bounds[1] to bounds[0]
283 }
284 from = anchors.getValue(a)
285 to = anchors.getValue(b)
286 fraction = (offset.value - a) / (b - a)
287 }
288 }
289 return SwipeProgress(from, to, fraction)
290 }
291
292 /**
293 * The direction in which the [swipeable] is moving, relative to the current [currentValue].
294 *
295 * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
296 * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
297 */
298 val direction: Float
<lambda>null299 get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
300
301 /**
302 * Set the state without any animation and suspend until it's set
303 *
304 * @param targetValue The new target value to set [currentValue] to.
305 */
snapTonull306 suspend fun snapTo(targetValue: T) {
307 latestNonEmptyAnchorsFlow.collect { anchors ->
308 val targetOffset = anchors.getOffset(targetValue)
309 requireNotNull(targetOffset) {
310 "The target value must have an associated anchor."
311 }
312 snapInternalToOffset(targetOffset)
313 currentValue = targetValue
314 }
315 }
316
317 /**
318 * Set the state to the target value by starting an animation.
319 *
320 * @param targetValue The new value to animate to.
321 * @param anim The animation that will be used to animate to the new value.
322 */
animateTonull323 suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
324 latestNonEmptyAnchorsFlow.collect { anchors ->
325 try {
326 val targetOffset = anchors.getOffset(targetValue)
327 requireNotNull(targetOffset) {
328 "The target value must have an associated anchor."
329 }
330 animateInternalToOffset(targetOffset, anim)
331 } finally {
332 val endOffset = absoluteOffset.value
333 val endValue = anchors
334 // fighting rounding error once again, anchor should be as close as 0.5 pixels
335 .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
336 .values.firstOrNull() ?: currentValue
337 currentValue = endValue
338 }
339 }
340 }
341
342 /**
343 * Perform fling with settling to one of the anchors which is determined by the given
344 * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided
345 * since it will settle at the anchor.
346 *
347 * In general cases, [swipeable] flings by itself when being swiped. This method is to be
348 * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
349 * want to trigger settling fling when the child scroll container reaches the bound.
350 *
351 * @param velocity velocity to fling and settle with
352 *
353 * @return the reason fling ended
354 */
performFlingnull355 suspend fun performFling(velocity: Float) {
356 latestNonEmptyAnchorsFlow.collect { anchors ->
357 val lastAnchor = anchors.getOffset(currentValue)!!
358 val targetValue = computeTarget(
359 offset = offset.value,
360 lastValue = lastAnchor,
361 anchors = anchors.keys,
362 thresholds = thresholds,
363 velocity = velocity,
364 velocityThreshold = velocityThreshold
365 )
366 val targetState = anchors[targetValue]
367 if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
368 // If the user vetoed the state change, rollback to the previous state.
369 else animateInternalToOffset(lastAnchor, animationSpec)
370 }
371 }
372
373 /**
374 * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable]
375 * gesture flow.
376 *
377 * Note: This method performs generic drag and it won't settle to any particular anchor, *
378 * leaving swipeable in between anchors. When done dragging, [performFling] must be
379 * called as well to ensure swipeable will settle at the anchor.
380 *
381 * In general cases, [swipeable] drags by itself when being swiped. This method is to be
382 * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
383 * want to force drag when the child scroll container reaches the bound.
384 *
385 * @param delta delta in pixels to drag by
386 *
387 * @return the amount of [delta] consumed
388 */
performDragnull389 fun performDrag(delta: Float): Float {
390 val potentiallyConsumed = absoluteOffset.value + delta
391 val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
392 val deltaToConsume = clamped - absoluteOffset.value
393 if (abs(deltaToConsume) > 0) {
394 draggableState.dispatchRawDelta(deltaToConsume)
395 }
396 return deltaToConsume
397 }
398
399 companion object {
400 /**
401 * The default [Saver] implementation for [SwipeableState].
402 */
Savernull403 fun <T : Any> Saver(
404 animationSpec: AnimationSpec<Float>,
405 confirmStateChange: (T) -> Boolean
406 ) = Saver<SwipeableState<T>, T>(
407 save = { it.currentValue },
<lambda>null408 restore = { SwipeableState(it, animationSpec, confirmStateChange) }
409 )
410 }
411 }
412
413 /**
414 * Collects information about the ongoing swipe or animation in [swipeable].
415 *
416 * To access this information, use [SwipeableState.progress].
417 *
418 * @param from The state corresponding to the anchor we are moving away from.
419 * @param to The state corresponding to the anchor we are moving towards.
420 * @param fraction The fraction that the current position represents between [from] and [to].
421 * Must be between `0` and `1`.
422 */
423 @Immutable
424 class SwipeProgress<T>(
425 val from: T,
426 val to: T,
427 /*@FloatRange(from = 0.0, to = 1.0)*/
428 val fraction: Float
429 ) {
equalsnull430 override fun equals(other: Any?): Boolean {
431 if (this === other) return true
432 if (other !is SwipeProgress<*>) return false
433
434 if (from != other.from) return false
435 if (to != other.to) return false
436 if (fraction != other.fraction) return false
437
438 return true
439 }
440
hashCodenull441 override fun hashCode(): Int {
442 var result = from?.hashCode() ?: 0
443 result = 31 * result + (to?.hashCode() ?: 0)
444 result = 31 * result + fraction.hashCode()
445 return result
446 }
447
toStringnull448 override fun toString(): String {
449 return "SwipeProgress(from=$from, to=$to, fraction=$fraction)"
450 }
451 }
452
453 /**
454 * Create and [remember] a [SwipeableState] with the default animation clock.
455 *
456 * @param initialValue The initial value of the state.
457 * @param animationSpec The default animation that will be used to animate to a new state.
458 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
459 */
460 @Composable
rememberSwipeableStatenull461 fun <T : Any> rememberSwipeableState(
462 initialValue: T,
463 animationSpec: AnimationSpec<Float> = AnimationSpec,
464 confirmStateChange: (newValue: T) -> Boolean = { true }
465 ): SwipeableState<T> {
466 return rememberSaveable(
467 saver = SwipeableState.Saver(
468 animationSpec = animationSpec,
469 confirmStateChange = confirmStateChange
470 )
<lambda>null471 ) {
472 SwipeableState(
473 initialValue = initialValue,
474 animationSpec = animationSpec,
475 confirmStateChange = confirmStateChange
476 )
477 }
478 }
479
480 /**
481 * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
482 * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
483 * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
484 * [value] will be notified to update their state to the new value of the [SwipeableState] by
485 * invoking [onValueChange]. If the owner does not update their state to the provided value for
486 * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
487 */
488 @Composable
rememberSwipeableStateFornull489 internal fun <T : Any> rememberSwipeableStateFor(
490 value: T,
491 onValueChange: (T) -> Unit,
492 animationSpec: AnimationSpec<Float> = AnimationSpec
493 ): SwipeableState<T> {
494 val swipeableState = remember {
495 SwipeableState(
496 initialValue = value,
497 animationSpec = animationSpec,
498 confirmStateChange = { true }
499 )
500 }
501 val forceAnimationCheck = remember { mutableStateOf(false) }
502 LaunchedEffect(value, forceAnimationCheck.value) {
503 if (value != swipeableState.currentValue) {
504 swipeableState.animateTo(value)
505 }
506 }
507 DisposableEffect(swipeableState.currentValue) {
508 if (value != swipeableState.currentValue) {
509 onValueChange(swipeableState.currentValue)
510 forceAnimationCheck.value = !forceAnimationCheck.value
511 }
512 onDispose { }
513 }
514 return swipeableState
515 }
516
517 /**
518 * Enable swipe gestures between a set of predefined states.
519 *
520 * To use this, you must provide a map of anchors (in pixels) to states (of type [T]).
521 * Note that this map cannot be empty and cannot have two anchors mapped to the same state.
522 *
523 * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe
524 * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`).
525 * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
526 * reached, the value of the [SwipeableState] will also be updated to the state corresponding to
527 * the new anchor. The target anchor is calculated based on the provided positional [thresholds].
528 *
529 * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe
530 * past these bounds, a resistance effect will be applied by default. The amount of resistance at
531 * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`.
532 *
533 * For an example of a [swipeable] with three states, see:
534 *
535 * @sample androidx.compose.material.samples.SwipeableSample
536 *
537 * @param T The type of the state.
538 * @param state The state of the [swipeable].
539 * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa.
540 * @param thresholds Specifies where the thresholds between the states are. The thresholds will be
541 * used to determine which state to animate to when swiping stops. This is represented as a lambda
542 * that takes two states and returns the threshold between them in the form of a [ThresholdConfig].
543 * Note that the order of the states corresponds to the swipe direction.
544 * @param orientation The orientation in which the [swipeable] can be swiped.
545 * @param enabled Whether this [swipeable] is enabled and should react to the user's input.
546 * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
547 * swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
548 * @param interactionSource Optional [MutableInteractionSource] that will passed on to
549 * the internal [Modifier.draggable].
550 * @param resistance Controls how much resistance will be applied when swiping past the bounds.
551 * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed
552 * in order to animate to the next state, even if the positional [thresholds] have not been reached.
553 */
swipeablenull554 fun <T> Modifier.swipeable(
555 state: SwipeableState<T>,
556 anchors: Map<Float, T>,
557 orientation: Orientation,
558 enabled: Boolean = true,
559 reverseDirection: Boolean = false,
560 interactionSource: MutableInteractionSource? = null,
561 thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
562 resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
563 velocityThreshold: Dp = VelocityThreshold
564 ) = composed(
<lambda>null565 inspectorInfo = debugInspectorInfo {
566 name = "swipeable"
567 properties["state"] = state
568 properties["anchors"] = anchors
569 properties["orientation"] = orientation
570 properties["enabled"] = enabled
571 properties["reverseDirection"] = reverseDirection
572 properties["interactionSource"] = interactionSource
573 properties["thresholds"] = thresholds
574 properties["resistance"] = resistance
575 properties["velocityThreshold"] = velocityThreshold
576 }
<lambda>null577 ) {
578 require(anchors.isNotEmpty()) {
579 "You must have at least one anchor."
580 }
581 require(anchors.values.distinct().count() == anchors.size) {
582 "You cannot have two anchors mapped to the same state."
583 }
584 val density = LocalDensity.current
585 state.ensureInit(anchors)
586 LaunchedEffect(anchors, state) {
587 val oldAnchors = state.anchors
588 state.anchors = anchors
589 state.resistance = resistance
590 state.thresholds = { a, b ->
591 val from = anchors.getValue(a)
592 val to = anchors.getValue(b)
593 with(thresholds(from, to)) { density.computeThreshold(a, b) }
594 }
595 with(density) {
596 state.velocityThreshold = velocityThreshold.toPx()
597 }
598 state.processNewAnchors(oldAnchors, anchors)
599 }
600
601 Modifier.draggable(
602 orientation = orientation,
603 enabled = enabled,
604 reverseDirection = reverseDirection,
605 interactionSource = interactionSource,
606 startDragImmediately = state.isAnimationRunning,
607 onDragStopped = { velocity -> launch { state.performFling(velocity) } },
608 state = state.draggableState
609 )
610 }
611
612 /**
613 * Interface to compute a threshold between two anchors/states in a [swipeable].
614 *
615 * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold].
616 */
617 @Stable
618 interface ThresholdConfig {
619 /**
620 * Compute the value of the threshold (in pixels), once the values of the anchors are known.
621 */
computeThresholdnull622 fun Density.computeThreshold(fromValue: Float, toValue: Float): Float
623 }
624
625 /**
626 * A fixed threshold will be at an [offset] away from the first anchor.
627 *
628 * @param offset The offset (in dp) that the threshold will be at.
629 */
630 @Immutable
631 data class FixedThreshold(private val offset: Dp) : ThresholdConfig {
632 override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
633 return fromValue + offset.toPx() * sign(toValue - fromValue)
634 }
635 }
636
637 /**
638 * A fractional threshold will be at a [fraction] of the way between the two anchors.
639 *
640 * @param fraction The fraction (between 0 and 1) that the threshold will be at.
641 */
642 @Immutable
643 data class FractionalThreshold(
644 /*@FloatRange(from = 0.0, to = 1.0)*/
645 private val fraction: Float
646 ) : ThresholdConfig {
computeThresholdnull647 override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
648 return lerp(fromValue, toValue, fraction)
649 }
650 }
651
652 /**
653 * Specifies how resistance is calculated in [swipeable].
654 *
655 * There are two things needed to calculate resistance: the resistance basis determines how much
656 * overflow will be consumed to achieve maximum resistance, and the resistance factor determines
657 * the amount of resistance (the larger the resistance factor, the stronger the resistance).
658 *
659 * The resistance basis is usually either the size of the component which [swipeable] is applied
660 * to, or the distance between the minimum and maximum anchors. For a constructor in which the
661 * resistance basis defaults to the latter, consider using [resistanceConfig].
662 *
663 * You may specify different resistance factors for each bound. Consider using one of the default
664 * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user
665 * has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe
666 * this right now. Also, you can set either factor to 0 to disable resistance at that bound.
667 *
668 * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive.
669 * @param factorAtMin The factor by which to scale the resistance at the minimum bound.
670 * Must not be negative.
671 * @param factorAtMax The factor by which to scale the resistance at the maximum bound.
672 * Must not be negative.
673 */
674 @Immutable
675 class ResistanceConfig(
676 /*@FloatRange(from = 0.0, fromInclusive = false)*/
677 val basis: Float,
678 /*@FloatRange(from = 0.0)*/
679 val factorAtMin: Float = StandardResistanceFactor,
680 /*@FloatRange(from = 0.0)*/
681 val factorAtMax: Float = StandardResistanceFactor
682 ) {
computeResistancenull683 fun computeResistance(overflow: Float): Float {
684 val factor = if (overflow < 0) factorAtMin else factorAtMax
685 if (factor == 0f) return 0f
686 val progress = (overflow / basis).coerceIn(-1f, 1f)
687 return basis / factor * sin(progress * PI.toFloat() / 2)
688 }
689
equalsnull690 override fun equals(other: Any?): Boolean {
691 if (this === other) return true
692 if (other !is ResistanceConfig) return false
693
694 if (basis != other.basis) return false
695 if (factorAtMin != other.factorAtMin) return false
696 if (factorAtMax != other.factorAtMax) return false
697
698 return true
699 }
700
hashCodenull701 override fun hashCode(): Int {
702 var result = basis.hashCode()
703 result = 31 * result + factorAtMin.hashCode()
704 result = 31 * result + factorAtMax.hashCode()
705 return result
706 }
707
toStringnull708 override fun toString(): String {
709 return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)"
710 }
711 }
712
713 /**
714 * Given an offset x and a set of anchors, return a list of anchors:
715 * 1. [ ] if the set of anchors is empty,
716 * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x'
717 * is x rounded to the exact value of the matching anchor,
718 * 3. [ min ] if min is the minimum anchor and x < min,
719 * 4. [ max ] if max is the maximum anchor and x > max, or
720 * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
721 */
findBoundsnull722 private fun findBounds(
723 offset: Float,
724 anchors: Set<Float>
725 ): List<Float> {
726 // Find the anchors the target lies between with a little bit of rounding error.
727 val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
728 val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
729
730 return when {
731 a == null ->
732 // case 1 or 3
733 listOfNotNull(b)
734 b == null ->
735 // case 4
736 listOf(a)
737 a == b ->
738 // case 2
739 // Can't return offset itself here since it might not be exactly equal
740 // to the anchor, despite being considered an exact match.
741 listOf(a)
742 else ->
743 // case 5
744 listOf(a, b)
745 }
746 }
747
computeTargetnull748 private fun computeTarget(
749 offset: Float,
750 lastValue: Float,
751 anchors: Set<Float>,
752 thresholds: (Float, Float) -> Float,
753 velocity: Float,
754 velocityThreshold: Float
755 ): Float {
756 val bounds = findBounds(offset, anchors)
757 return when (bounds.size) {
758 0 -> lastValue
759 1 -> bounds[0]
760 else -> {
761 val lower = bounds[0]
762 val upper = bounds[1]
763 if (lastValue <= offset) {
764 // Swiping from lower to upper (positive).
765 if (velocity >= velocityThreshold) {
766 return upper
767 } else {
768 val threshold = thresholds(lower, upper)
769 if (offset < threshold) lower else upper
770 }
771 } else {
772 // Swiping from upper to lower (negative).
773 if (velocity <= -velocityThreshold) {
774 return lower
775 } else {
776 val threshold = thresholds(upper, lower)
777 if (offset > threshold) upper else lower
778 }
779 }
780 }
781 }
782 }
783
getOffsetnull784 private fun <T> Map<Float, T>.getOffset(state: T): Float? {
785 return entries.firstOrNull { it.value == state }?.key
786 }
787
788 /**
789 * Contains useful defaults for [swipeable] and [SwipeableState].
790 */
791 object SwipeableDefaults {
792 /**
793 * The default animation used by [SwipeableState].
794 */
795 val AnimationSpec = SpringSpec<Float>(stiffness = Spring.StiffnessMediumLow)
796
797 /**
798 * The default animation duration used by Scrim in enter/exit transitions.
799 */
800 val DefaultDurationMillis: Int = 400
801
802 /**
803 * The default velocity threshold (1.8 dp per millisecond) used by [swipeable].
804 */
805 val VelocityThreshold = 125.dp
806
807 /**
808 * A stiff resistance factor which indicates that swiping isn't available right now.
809 */
810 const val StiffResistanceFactor = 20f
811
812 /**
813 * A standard resistance factor which indicates that the user has run out of things to see.
814 */
815 const val StandardResistanceFactor = 10f
816
817 /**
818 * The default resistance config used by [swipeable].
819 *
820 * This returns `null` if there is one anchor. If there are at least two anchors, it returns
821 * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds.
822 */
resistanceConfignull823 fun resistanceConfig(
824 anchors: Set<Float>,
825 factorAtMin: Float = StandardResistanceFactor,
826 factorAtMax: Float = StandardResistanceFactor
827 ): ResistanceConfig? {
828 return if (anchors.size <= 1) {
829 null
830 } else {
831 val basis = anchors.maxOrNull()!! - anchors.minOrNull()!!
832 ResistanceConfig(basis, factorAtMin, factorAtMax)
833 }
834 }
835 }
836
837 // temp default nested scroll connection for swipeables which desire as an opt in
838 // revisit in b/174756744 as all types will have their own specific connection probably
839 internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
840 get() = object : NestedScrollConnection {
onPreScrollnull841 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
842 val delta = available.toFloat()
843 return if (delta < 0 && source == NestedScrollSource.Drag) {
844 performDrag(delta).toOffset()
845 } else {
846 Offset.Zero
847 }
848 }
849
onPostScrollnull850 override fun onPostScroll(
851 consumed: Offset,
852 available: Offset,
853 source: NestedScrollSource
854 ): Offset {
855 return if (source == NestedScrollSource.Drag) {
856 performDrag(available.toFloat()).toOffset()
857 } else {
858 Offset.Zero
859 }
860 }
861
onPreFlingnull862 override suspend fun onPreFling(available: Velocity): Velocity {
863 val toFling = Offset(available.x, available.y).toFloat()
864 return if (toFling < 0 && offset.value > minBound) {
865 performFling(velocity = toFling)
866 // since we go to the anchor with tween settling, consume all for the best UX
867 available
868 } else {
869 Velocity.Zero
870 }
871 }
872
onPostFlingnull873 override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
874 performFling(velocity = Offset(available.x, available.y).toFloat())
875 return available
876 }
877
Floatnull878 private fun Float.toOffset(): Offset = Offset(0f, this)
879
880 private fun Offset.toFloat(): Float = this.y
881 }