1 /* 2 * 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>> = 127 snapshotFlow { anchors } 128 .filter { it.isNotEmpty() } 129 .take(1) 130 131 internal var minBound = Float.NEGATIVE_INFINITY 132 internal var maxBound = Float.POSITIVE_INFINITY 133 134 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 146 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 195 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 201 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 211 private suspend fun snapInternalToOffset(target: Float) { 212 draggableState.drag { 213 dragBy(target - absoluteOffset.value) 214 } 215 } 216 217 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 299 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 */ 306 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 */ 323 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 */ 355 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 */ 389 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 */ 403 fun <T : Any> Saver( 404 animationSpec: AnimationSpec<Float>, 405 confirmStateChange: (T) -> Boolean 406 ) = Saver<SwipeableState<T>, T>( 407 save = { it.currentValue }, 408 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 ) { 430 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 441 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 448 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 461 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 ) 471 ) { 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 489 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 */ 554 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( 565 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 } 577 ) { 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 */ 622 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 { 647 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 ) { 683 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 690 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 701 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 708 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 */ 722 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 748 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 784 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 */ 823 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 { 841 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 850 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 862 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 873 override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { 874 performFling(velocity = Offset(available.x, available.y).toFloat()) 875 return available 876 } 877 878 private fun Float.toOffset(): Offset = Offset(0f, this) 879 880 private fun Offset.toFloat(): Float = this.y 881 }