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