1 /*
2 * Copyright 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 * https://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 @file:OptIn(ExperimentalHorologistApi::class)
18
19 package com.google.android.horologist.compose.rotaryinput
20
21 import android.view.ViewConfiguration
22 import androidx.compose.animation.core.AnimationState
23 import androidx.compose.animation.core.CubicBezierEasing
24 import androidx.compose.animation.core.Easing
25 import androidx.compose.animation.core.FastOutSlowInEasing
26 import androidx.compose.animation.core.SpringSpec
27 import androidx.compose.animation.core.animateTo
28 import androidx.compose.animation.core.copy
29 import androidx.compose.animation.core.spring
30 import androidx.compose.animation.core.tween
31 import androidx.compose.foundation.MutatePriority
32 import androidx.compose.foundation.focusable
33 import androidx.compose.foundation.gestures.FlingBehavior
34 import androidx.compose.foundation.gestures.ScrollableDefaults
35 import androidx.compose.foundation.gestures.ScrollableState
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.LaunchedEffect
38 import androidx.compose.runtime.remember
39 import androidx.compose.ui.ExperimentalComposeUiApi
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.composed
42 import androidx.compose.ui.focus.FocusRequester
43 import androidx.compose.ui.focus.focusRequester
44 import androidx.compose.ui.input.rotary.onRotaryScrollEvent
45 import androidx.compose.ui.platform.LocalContext
46 import androidx.compose.ui.platform.LocalDensity
47 import androidx.compose.ui.unit.Dp
48 import androidx.compose.ui.util.fastSumBy
49 import androidx.compose.ui.util.lerp
50 import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
51 import androidx.wear.compose.foundation.lazy.ScalingLazyListState
52 import androidx.wear.compose.foundation.rememberActiveFocusRequester
53 import com.google.android.horologist.annotations.ExperimentalHorologistApi
54 import kotlinx.coroutines.CompletableDeferred
55 import kotlinx.coroutines.CoroutineScope
56 import kotlinx.coroutines.ExperimentalCoroutinesApi
57 import kotlinx.coroutines.Job
58 import kotlinx.coroutines.async
59 import kotlinx.coroutines.channels.Channel
60 import kotlinx.coroutines.delay
61 import kotlinx.coroutines.flow.Flow
62 import kotlinx.coroutines.flow.collectLatest
63 import kotlinx.coroutines.flow.receiveAsFlow
64 import kotlinx.coroutines.flow.transformLatest
65 import kotlin.math.abs
66 import kotlin.math.absoluteValue
67 import kotlin.math.sign
68
69 private const val DEBUG = false
70
71 /**
72 * Debug logging that can be enabled.
73 */
debugLognull74 private inline fun debugLog(generateMsg: () -> String) {
75 if (DEBUG) {
76 println("RotaryScroll: ${generateMsg()}")
77 }
78 }
79
80 /**
81 * A modifier which connects rotary events with scrollable.
82 * This modifier supports fling.
83 *
84 * Fling algorithm:
85 * - A scroll with RSB/ Bezel happens.
86 * - If this is a first rotary event after the threshold ( by default 200ms), a new scroll
87 * session starts by resetting all necessary parameters
88 * - A delta value is added into VelocityTracker and a new speed is calculated.
89 * - If the current speed is bigger than the previous one, this value is remembered as
90 * a latest fling speed with a timestamp
91 * - After each scroll event a fling countdown starts ( by default 70ms) which
92 * resets if new scroll event is received
93 * - If fling countdown is finished - it means that the finger was probably raised from RSB, there will be no other events and probably
94 * this is the last event during this session. After it a fling is triggered.
95 * - Fling is stopped when a new scroll event happens
96 *
97 * The screen containing the scrollable item should request the focus
98 * by calling [requestFocus] method
99 *
100 * ```
101 * LaunchedEffect(Unit) {
102 * focusRequester.requestFocus()
103 * }
104 * ```
105 * @param focusRequester Requests the focus for rotary input
106 * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
107 * @param flingBehavior Logic describing fling behavior.
108 * @param rotaryHaptics Class which will handle haptic feedback
109 * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
110 * Scrollable `reverseDirection` parameter
111 */
112 @ExperimentalHorologistApi
113 @Suppress("ComposableModifierFactory")
114 @Deprecated(
115 "Use rotaryWithScroll instead",
116 ReplaceWith(
117 "this.rotaryWithScroll(scrollableState, focusRequester, " +
118 "flingBehavior, rotaryHaptics, reverseDirection)",
119 ),
120 )
121 @Composable
rotaryWithFlingnull122 public fun Modifier.rotaryWithFling(
123 focusRequester: FocusRequester,
124 scrollableState: ScrollableState,
125 flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
126 rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState),
127 reverseDirection: Boolean = false,
128 ): Modifier = rotaryHandler(
129 rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior),
130 reverseDirection = reverseDirection,
131 rotaryHaptics = rotaryHaptics,
132 )
133 .focusRequester(focusRequester)
134 .focusable()
135
136 /**
137 * A modifier which connects rotary events with scrollable.
138 * This modifier supports scroll with fling.
139 *
140 * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
141 * @param focusRequester Requests the focus for rotary input.
142 * By default comes from [rememberActiveFocusRequester],
143 * which is used with [HierarchicalFocusCoordinator]
144 * @param flingBehavior Logic describing fling behavior. If null fling will not happen.
145 * @param rotaryHaptics Class which will handle haptic feedback
146 * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
147 * Scrollable `reverseDirection` parameter
148 */
149 @OptIn(ExperimentalWearFoundationApi::class)
150 @ExperimentalHorologistApi
151 @Suppress("ComposableModifierFactory")
152 @Composable
153 public fun Modifier.rotaryWithScroll(
154 scrollableState: ScrollableState,
155 focusRequester: FocusRequester = rememberActiveFocusRequester(),
156 flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(),
157 rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState),
158 reverseDirection: Boolean = false,
159 ): Modifier = rotaryHandler(
160 rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior),
161 reverseDirection = reverseDirection,
162 rotaryHaptics = rotaryHaptics,
163 )
164 .focusRequester(focusRequester)
165 .focusable()
166
167 /**
168 * A modifier which connects rotary events with scrollable.
169 * This modifier supports snap.
170 *
171 * @param focusRequester Requests the focus for rotary input.
172 * By default comes from [rememberActiveFocusRequester],
173 * which is used with [HierarchicalFocusCoordinator]
174 * @param rotaryScrollAdapter A connection between scrollable objects and rotary events
175 * @param rotaryHaptics Class which will handle haptic feedback
176 * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
177 * Scrollable `reverseDirection` parameter
178 */
179 @OptIn(ExperimentalWearFoundationApi::class)
180 @ExperimentalHorologistApi
181 @Suppress("ComposableModifierFactory")
182 @Composable
183 public fun Modifier.rotaryWithSnap(
184 rotaryScrollAdapter: RotaryScrollAdapter,
185 focusRequester: FocusRequester = rememberActiveFocusRequester(),
186 snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault(),
187 rotaryHaptics: RotaryHapticHandler =
188 rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState),
189 reverseDirection: Boolean = false,
190 ): Modifier = rotaryHandler(
191 rotaryScrollHandler =
192 RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters),
193 reverseDirection = reverseDirection,
194 rotaryHaptics = rotaryHaptics,
195 )
196 .focusRequester(focusRequester)
197 .focusable()
198
199 /**
200 * An extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState]
201 */
202 @ExperimentalHorologistApi
203 public fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter =
204 ScalingLazyColumnRotaryScrollAdapter(this)
205
206 /**
207 * An implementation of rotary scroll adapter for [ScalingLazyColumn]
208 */
209 @ExperimentalHorologistApi
210 public class ScalingLazyColumnRotaryScrollAdapter(
211 override val scrollableState: ScalingLazyListState,
212 ) : RotaryScrollAdapter {
213
214 /**
215 * Calculates an average height of an item by taking an average from visible items height.
216 */
217 override fun averageItemSize(): Float {
218 val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
219 return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
220 }
221
222 /**
223 * Current (centred) item index
224 */
225 override fun currentItemIndex(): Int = scrollableState.centerItemIndex
226
227 /**
228 * An offset from the item centre
229 */
230 override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat()
231
232 /**
233 * The total count of items in ScalingLazyColumn
234 */
235 override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount
236 }
237
238 /**
239 * An adapter which connects scrollableState to Rotary
240 */
241 @ExperimentalHorologistApi
242 public interface RotaryScrollAdapter {
243
244 /**
245 * A scrollable state. Used for performing scroll when Rotary events received
246 */
247 @ExperimentalHorologistApi
248 public val scrollableState: ScrollableState
249
250 /**
251 * Average size of an item. Used for estimating the scrollable distance
252 */
253 @ExperimentalHorologistApi
averageItemSizenull254 public fun averageItemSize(): Float
255
256 /**
257 * A current item index. Used for scrolling
258 */
259 @ExperimentalHorologistApi
260 public fun currentItemIndex(): Int
261
262 /**
263 * An offset from the centre or the border of the current item.
264 */
265 @ExperimentalHorologistApi
266 public fun currentItemOffset(): Float
267
268 /**
269 * The total count of items in [scrollableState]
270 */
271 @ExperimentalHorologistApi
272 public fun totalItemsCount(): Int
273 }
274
275 /**
276 * Defaults for rotary modifiers
277 */
278 @ExperimentalHorologistApi
279 public object RotaryDefaults {
280
281 /**
282 * Handles scroll with fling.
283 * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
284 * @param flingBehavior Logic describing Fling behavior. If null - fling will not happen
285 * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
286 */
287 @ExperimentalHorologistApi
288 @Composable
289 public fun rememberFlingHandler(
290 scrollableState: ScrollableState,
291 flingBehavior: FlingBehavior? = null,
292 isLowRes: Boolean = isLowResInput(),
293 ): RotaryScrollHandler {
294 val viewConfiguration = ViewConfiguration.get(LocalContext.current)
295
296 return remember(scrollableState, flingBehavior, isLowRes) {
297 debugLog { "isLowRes : $isLowRes" }
298 fun rotaryFlingBehavior() = flingBehavior?.run {
299 DefaultRotaryFlingBehavior(
300 scrollableState,
301 flingBehavior,
302 viewConfiguration,
303 flingTimeframe =
304 if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe,
305 )
306 }
307
308 fun scrollBehavior() = AnimationScrollBehavior(scrollableState)
309
310 if (isLowRes) {
311 LowResRotaryScrollHandler(
312 rotaryFlingBehaviorFactory = { rotaryFlingBehavior() },
313 scrollBehaviorFactory = { scrollBehavior() },
314 )
315 } else {
316 HighResRotaryScrollHandler(
317 rotaryFlingBehaviorFactory = { rotaryFlingBehavior() },
318 scrollBehaviorFactory = { scrollBehavior() },
319 )
320 }
321 }
322 }
323
324 /**
325 * Handles scroll with snap
326 * @param rotaryScrollAdapter A connection between scrollable objects and rotary events
327 * @param snapParameters Snap parameters
328 */
329 @ExperimentalHorologistApi
330 @Composable
331 public fun rememberSnapHandler(
332 rotaryScrollAdapter: RotaryScrollAdapter,
333 snapParameters: SnapParameters = snapParametersDefault(),
334 isLowRes: Boolean = isLowResInput(),
335 ): RotaryScrollHandler {
336 return remember(rotaryScrollAdapter, snapParameters) {
337 if (isLowRes) {
338 LowResSnapHandler(
339 snapBehaviourFactory = {
340 DefaultSnapBehavior(rotaryScrollAdapter, snapParameters)
341 },
342 )
343 } else {
344 HighResSnapHandler(
345 resistanceFactor = snapParameters.resistanceFactor,
346 thresholdBehaviorFactory = {
347 ThresholdBehavior(
348 rotaryScrollAdapter,
349 snapParameters.thresholdDivider,
350 )
351 },
352 snapBehaviourFactory = {
353 DefaultSnapBehavior(rotaryScrollAdapter, snapParameters)
354 },
355 scrollBehaviourFactory = {
356 AnimationScrollBehavior(rotaryScrollAdapter.scrollableState)
357 },
358 )
359 }
360 }
361 }
362
363 /**
364 * Returns default [SnapParameters]
365 */
366 @ExperimentalHorologistApi
367 public fun snapParametersDefault(): SnapParameters =
368 SnapParameters(
369 snapOffset = 0,
370 thresholdDivider = 1.5f,
371 resistanceFactor = 3f,
372 )
373
374 /**
375 * Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb).
376 */
377 @ExperimentalHorologistApi
378 @Composable
379 public fun isLowResInput(): Boolean = LocalContext.current.packageManager
380 .hasSystemFeature("android.hardware.rotaryencoder.lowres")
381
382 private val lowResFlingTimeframe: Long = 100L
383 private val highResFlingTimeframe: Long = 30L
384 }
385
386 /**
387 * Parameters used for snapping
388 *
389 * @param snapOffset an optional offset to be applied when snapping the item. After the snap the
390 * snapped items offset will be [snapOffset].
391 */
392 public class SnapParameters(
393 public val snapOffset: Int,
394 public val thresholdDivider: Float,
395 public val resistanceFactor: Float,
396 ) {
397 /**
398 * Returns a snapping offset in [Dp]
399 */
400 @Composable
snapOffsetDpnull401 public fun snapOffsetDp(): Dp {
402 return with(LocalDensity.current) {
403 snapOffset.toDp()
404 }
405 }
406 }
407
408 /**
409 * An interface for handling scroll events
410 */
411 @ExperimentalHorologistApi
412 public interface RotaryScrollHandler {
413 /**
414 * Handles scrolling events
415 * @param coroutineScope A scope for performing async actions
416 * @param event A scrollable event from rotary input, containing scrollable delta and timestamp
417 * @param rotaryHaptics
418 */
419 @ExperimentalHorologistApi
handleScrollEventnull420 public suspend fun handleScrollEvent(
421 coroutineScope: CoroutineScope,
422 event: TimestampedDelta,
423 rotaryHaptics: RotaryHapticHandler,
424 )
425 }
426
427 /**
428 * An interface for scrolling behavior
429 */
430 @ExperimentalHorologistApi
431 public interface RotaryScrollBehavior {
432 /**
433 * Handles scroll event to [targetValue]
434 */
435 @ExperimentalHorologistApi
436 public suspend fun handleEvent(targetValue: Float)
437 }
438
439 /**
440 * Default implementation of [RotaryFlingBehavior]
441 */
442 @ExperimentalHorologistApi
443 public class DefaultRotaryFlingBehavior(
444 private val scrollableState: ScrollableState,
445 private val flingBehavior: FlingBehavior,
446 viewConfiguration: ViewConfiguration,
447 private val flingTimeframe: Long,
448 ) : RotaryFlingBehavior {
449
450 // A time range during which the fling is valid.
451 // For simplicity it's twice as long as [flingTimeframe]
452 private val timeRangeToFling = flingTimeframe * 2
453
454 // A default fling factor for making fling slower
455 private val flingScaleFactor = 0.7f
456
457 private var previousVelocity = 0f
458
459 private val rotaryVelocityTracker = RotaryVelocityTracker()
460
461 private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat()
462 private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat()
463 private var latestEventTimestamp: Long = 0
464
465 private var flingVelocity: Float = 0f
466 private var flingTimestamp: Long = 0
467
468 @ExperimentalHorologistApi
startFlingTrackingnull469 override fun startFlingTracking(timestamp: Long) {
470 rotaryVelocityTracker.start(timestamp)
471 latestEventTimestamp = timestamp
472 previousVelocity = 0f
473 }
474
475 @ExperimentalHorologistApi
observeEventnull476 override fun observeEvent(timestamp: Long, delta: Float) {
477 rotaryVelocityTracker.move(timestamp, delta)
478 latestEventTimestamp = timestamp
479 }
480
481 @ExperimentalHorologistApi
trackFlingnull482 override suspend fun trackFling(beforeFling: () -> Unit) {
483 val currentVelocity = rotaryVelocityTracker.velocity
484 debugLog { "currentVelocity: $currentVelocity" }
485
486 if (abs(currentVelocity) >= abs(previousVelocity)) {
487 flingTimestamp = latestEventTimestamp
488 flingVelocity = currentVelocity * flingScaleFactor
489 }
490 previousVelocity = currentVelocity
491
492 // Waiting for a fixed amount of time before checking the fling
493 delay(flingTimeframe)
494
495 // For making a fling 2 criteria should be met:
496 // 1) no more than
497 // `rangeToFling` ms should pass between last fling detection
498 // and the time of last motion event
499 // 2) flingVelocity should exceed the minFlingSpeed
500 debugLog {
501 "Check fling: flingVelocity: $flingVelocity " +
502 "minFlingSpeed: $minFlingSpeed, maxFlingSpeed: $maxFlingSpeed"
503 }
504 if (latestEventTimestamp - flingTimestamp < timeRangeToFling &&
505 abs(flingVelocity) > minFlingSpeed
506 ) {
507 // Stops scrollAnimationCoroutine because a fling will be performed
508 beforeFling()
509 val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed)
510 scrollableState.scroll(MutatePriority.UserInput) {
511 with(flingBehavior) {
512 debugLog { "Flinging with velocity $velocity" }
513 performFling(velocity)
514 }
515 }
516 }
517 }
518 }
519
520 /**
521 * An interface for flinging with rotary
522 */
523 @ExperimentalHorologistApi
524 public interface RotaryFlingBehavior {
525
526 /**
527 * Observing new event within a fling tracking session with new timestamp and delta
528 */
529 @ExperimentalHorologistApi
observeEventnull530 public fun observeEvent(timestamp: Long, delta: Float)
531
532 /**
533 * Performing fling if necessary and calling [beforeFling] lambda before it is triggered
534 */
535 @ExperimentalHorologistApi
536 public suspend fun trackFling(beforeFling: () -> Unit)
537
538 /**
539 * Starts a new fling tracking session
540 * with specified timestamp
541 */
542 @ExperimentalHorologistApi
543 public fun startFlingTracking(timestamp: Long)
544 }
545
546 /**
547 * An interface for snapping with rotary
548 */
549 @ExperimentalHorologistApi
550 public interface RotarySnapBehavior {
551
552 /**
553 * Preparing snapping. This method should be called before [snapToTargetItem] is called.
554 *
555 * Snapping is done for current + [moveForElements] items.
556 *
557 * If [sequentialSnap] is true, items are summed up together.
558 * For example, if [prepareSnapForItems] is called with
559 * [moveForElements] = 2, 3, 5 -> then the snapping will happen to current + 10 items
560 *
561 * If [sequentialSnap] is false, then [moveForElements] are not summed up together.
562 */
563 public fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean)
564
565 /**
566 * Performs snapping to the closest item.
567 */
568 public suspend fun snapToClosestItem()
569
570 /**
571 * Returns true if top edge was reached
572 */
573 public fun topEdgeReached(): Boolean
574
575 /**
576 * Returns true if bottom edge was reached
577 */
578 public fun bottomEdgeReached(): Boolean
579
580 /**
581 * Performs snapping to the specified in [prepareSnapForItems] element
582 */
583 public suspend fun snapToTargetItem()
584 }
585
586 /**
587 * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta].
588 */
589 @ExperimentalHorologistApi
590 public data class TimestampedDelta(val timestamp: Long, val delta: Float)
591
592 /** Animation implementation of [RotaryScrollBehavior].
593 * This class does a smooth animation when the scroll by N pixels is done.
594 * This animation works well on Rsb(high-res) and Bezel(low-res) devices.
595 */
596 @ExperimentalHorologistApi
597 public class AnimationScrollBehavior(
598 private val scrollableState: ScrollableState,
599 ) : RotaryScrollBehavior {
600 private var sequentialAnimation = false
601 private var scrollAnimation = AnimationState(0f)
602 private var prevPosition = 0f
603
604 @ExperimentalHorologistApi
handleEventnull605 override suspend fun handleEvent(targetValue: Float) {
606 scrollableState.scroll(MutatePriority.UserInput) {
607 debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" }
608
609 scrollAnimation.animateTo(
610 targetValue,
611 animationSpec = spring(),
612 sequentialAnimation = sequentialAnimation,
613 ) {
614 val delta = value - prevPosition
615 debugLog { "Animated by $delta, value: $value" }
616 scrollBy(delta)
617 prevPosition = value
618 sequentialAnimation = value != this.targetValue
619 }
620 }
621 }
622 }
623
624 /**
625 * An animated implementation of [RotarySnapBehavior]. Uses animateScrollToItem
626 * method for snapping to the Nth item
627 */
628 @ExperimentalHorologistApi
629 public class DefaultSnapBehavior(
630 private val rotaryScrollAdapter: RotaryScrollAdapter,
631 private val snapParameters: SnapParameters,
632 ) : RotarySnapBehavior {
633 private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex()
634 private var sequentialSnap: Boolean = false
635
636 private var anim = AnimationState(0f)
637 private var expectedDistance = 0f
638
639 private val defaultStiffness = 200f
640 private var snapTargetUpdated = true
641
642 @ExperimentalHorologistApi
prepareSnapForItemsnull643 override fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) {
644 this.sequentialSnap = sequentialSnap
645 if (sequentialSnap) {
646 snapTarget += moveForElements
647 } else {
648 snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements
649 }
650 snapTargetUpdated = true
651 snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount())
652 }
653
snapToClosestItemnull654 override suspend fun snapToClosestItem() {
655 // Snapping to the closest item by using performFling method with 0 speed
656 rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
657 debugLog { "snap to closest item" }
658 var prevPosition = 0f
659 AnimationState(0f).animateTo(
660 targetValue = -rotaryScrollAdapter.currentItemOffset(),
661 animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing),
662 ) {
663 val animDelta = value - prevPosition
664 scrollBy(animDelta)
665 prevPosition = value
666 }
667 snapTarget = rotaryScrollAdapter.currentItemIndex()
668 }
669 }
670
topEdgeReachednull671 override fun topEdgeReached(): Boolean = snapTarget <= 0
672
673 override fun bottomEdgeReached(): Boolean =
674 snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1
675
676 override suspend fun snapToTargetItem() {
677 if (sequentialSnap) {
678 anim = anim.copy(0f)
679 } else {
680 anim = AnimationState(0f)
681 }
682 rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
683 // If snapTargetUpdated is true - then the target was updated so we
684 // need to do snap again
685 while (snapTargetUpdated) {
686 snapTargetUpdated = false
687 var latestCenterItem: Int
688 var continueFirstScroll = true
689 debugLog { "snapTarget $snapTarget" }
690 while (continueFirstScroll) {
691 latestCenterItem = rotaryScrollAdapter.currentItemIndex()
692 anim = anim.copy(0f)
693 expectedDistance = expectedDistanceTo(snapTarget, snapParameters.snapOffset)
694 debugLog {
695 "expectedDistance = $expectedDistance, " +
696 "scrollableState.centerItemScrollOffset " +
697 "${rotaryScrollAdapter.currentItemOffset()}"
698 }
699 continueFirstScroll = false
700 var prevPosition = 0f
701
702 anim.animateTo(
703 expectedDistance,
704 animationSpec = SpringSpec(
705 stiffness = defaultStiffness,
706 visibilityThreshold = 0.1f,
707 ),
708 sequentialAnimation = (anim.velocity != 0f),
709 ) {
710 val animDelta = value - prevPosition
711 debugLog {
712 "First animation, value:$value, velocity:$velocity, " +
713 "animDelta:$animDelta"
714 }
715
716 // Exit animation if snap target was updated
717 if (snapTargetUpdated) cancelAnimation()
718
719 scrollBy(animDelta)
720 prevPosition = value
721
722 if (latestCenterItem != rotaryScrollAdapter.currentItemIndex()) {
723 continueFirstScroll = true
724 cancelAnimation()
725 return@animateTo
726 }
727
728 debugLog { "centerItemIndex = ${rotaryScrollAdapter.currentItemIndex()}" }
729 if (rotaryScrollAdapter.currentItemIndex() == snapTarget) {
730 debugLog { "Target is visible. Cancelling first animation" }
731 debugLog {
732 "scrollableState.centerItemScrollOffset " +
733 "${rotaryScrollAdapter.currentItemOffset()}"
734 }
735 expectedDistance = -rotaryScrollAdapter.currentItemOffset()
736 continueFirstScroll = false
737 cancelAnimation()
738 return@animateTo
739 }
740 }
741 }
742 // Exit animation if snap target was updated
743 if (snapTargetUpdated) continue
744
745 anim = anim.copy(0f)
746 var prevPosition = 0f
747 anim.animateTo(
748 expectedDistance,
749 animationSpec = SpringSpec(
750 stiffness = defaultStiffness,
751 visibilityThreshold = 0.1f,
752 ),
753 sequentialAnimation = (anim.velocity != 0f),
754 ) {
755 // Exit animation if snap target was updated
756 if (snapTargetUpdated) cancelAnimation()
757
758 val animDelta = value - prevPosition
759 debugLog { "Final animation. velocity:$velocity, animDelta:$animDelta" }
760 scrollBy(animDelta)
761 prevPosition = value
762 }
763 }
764 }
765 }
766
expectedDistanceTonull767 private fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
768 val averageSize = rotaryScrollAdapter.averageItemSize()
769 val indexesDiff = index - rotaryScrollAdapter.currentItemIndex()
770 debugLog { "Average size $averageSize" }
771 return (averageSize * indexesDiff) +
772 targetScrollOffset - rotaryScrollAdapter.currentItemOffset()
773 }
774 }
775
776 /**
777 * A modifier which handles rotary events.
778 * It accepts ScrollHandler as the input - a class where main logic about how
779 * scroll should be handled is lying
780 */
781 @ExperimentalHorologistApi
782 @OptIn(ExperimentalComposeUiApi::class)
rotaryHandlernull783 public fun Modifier.rotaryHandler(
784 rotaryScrollHandler: RotaryScrollHandler,
785 // TODO: batching causes additional delays. Return once it's clear that
786 // we will use it
787 /* batchTimeframe: Long = 0L,*/
788 reverseDirection: Boolean,
789 rotaryHaptics: RotaryHapticHandler,
790 ): Modifier = composed {
791 val channel = rememberTimestampChannel()
792 val eventsFlow = remember(channel) { channel.receiveAsFlow() }
793
794 composed {
795 LaunchedEffect(eventsFlow) {
796 eventsFlow
797 // TODO: batching causes additional delays. Return once it's clear that
798 // we will use it
799 // Do we really need to do this on this level?
800 // .batchRequestsWithinTimeframe(batchTimeframe)
801 .collectLatest {
802 debugLog {
803 "Scroll event received: " +
804 "delta:${it.delta}, timestamp:${it.timestamp}"
805 }
806 rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics)
807 }
808 }
809 this
810 .onRotaryScrollEvent {
811 // Okay to ignore the ChannelResult returned from trySend because it is conflated
812 // (see rememberTimestampChannel()).
813 @Suppress("UNUSED_VARIABLE")
814 val unused = channel.trySend(
815 TimestampedDelta(
816 it.uptimeMillis,
817 it.verticalScrollPixels * if (reverseDirection) -1f else 1f,
818 ),
819 )
820 true
821 }
822 }
823 }
824
825 /**
826 * Batching requests for scrolling events. This function combines all events together
827 * (except first) within specified timeframe. Should help with performance on high-res devices.
828 */
829 @ExperimentalHorologistApi
830 @OptIn(ExperimentalCoroutinesApi::class)
batchRequestsWithinTimeframenull831 public fun Flow<TimestampedDelta>.batchRequestsWithinTimeframe(
832 timeframe: Long
833 ): Flow<TimestampedDelta> {
834 var delta = 0f
835 var lastTimestamp = -timeframe
836 return if (timeframe == 0L) {
837 this
838 } else {
839 this.transformLatest {
840 delta += it.delta
841 debugLog { "Batching requests. delta:$delta" }
842 if (lastTimestamp + timeframe <= it.timestamp) {
843 lastTimestamp = it.timestamp
844 debugLog { "No events before, delta= $delta" }
845 emit(TimestampedDelta(it.timestamp, delta))
846 } else {
847 delay(timeframe)
848 debugLog { "After delay, delta= $delta" }
849 if (delta > 0f) {
850 emit(TimestampedDelta(it.timestamp, delta))
851 }
852 }
853 delta = 0f
854 }
855 }
856 }
857
858 /**
859 * A scroll handler for RSB(high-res) without snapping and with or without fling
860 * A list is scrolled by the number of pixels received from the rotary device.
861 *
862 * This class is a little bit different from LowResScrollHandler class - it has a filtering
863 * for events which are coming with wrong sign ( this happens to rsb devices,
864 * especially at the end of the scroll)
865 *
866 * This scroll handler supports fling. It can be set with [RotaryFlingBehavior].
867 */
868 internal class HighResRotaryScrollHandler(
869 private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?,
870 private val scrollBehaviorFactory: () -> RotaryScrollBehavior,
871 private val hapticsThreshold: Long = 50,
872 ) : RotaryScrollHandler {
873
874 // This constant is specific for high-res devices. Because that input values
875 // can sometimes come with different sign, we have to filter them in this threshold
876 private val gestureThresholdTime = 200L
877 private var scrollJob: Job = CompletableDeferred<Unit>()
878 private var flingJob: Job = CompletableDeferred<Unit>()
879
880 private var previousScrollEventTime = 0L
881 private var rotaryScrollDistance = 0f
882
883 private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory()
884 private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory()
885
handleScrollEventnull886 override suspend fun handleScrollEvent(
887 coroutineScope: CoroutineScope,
888 event: TimestampedDelta,
889 rotaryHaptics: RotaryHapticHandler,
890 ) {
891 val time = event.timestamp
892 val isOppositeScrollValue = isOppositeValueAfterScroll(event.delta)
893
894 if (isNewScrollEvent(time)) {
895 debugLog { "New scroll event" }
896 resetTracking(time)
897 rotaryScrollDistance = event.delta
898 } else {
899 // Due to the physics of Rotary side button, some events might come
900 // with an opposite axis value - either at the start or at the end of the motion.
901 // We don't want to use these values for fling calculations.
902 if (!isOppositeScrollValue) {
903 rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta)
904 } else {
905 debugLog { "Opposite value after scroll :${event.delta}" }
906 }
907 rotaryScrollDistance += event.delta
908 }
909
910 scrollJob.cancel()
911
912 rotaryHaptics.handleScrollHaptic(event.delta)
913 debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
914
915 previousScrollEventTime = time
916 scrollJob = coroutineScope.async {
917 scrollBehavior.handleEvent(rotaryScrollDistance)
918 }
919
920 if (rotaryFlingBehavior != null) {
921 flingJob.cancel()
922 flingJob = coroutineScope.async {
923 rotaryFlingBehavior?.trackFling(beforeFling = {
924 debugLog { "Calling before fling section" }
925 scrollJob.cancel()
926 scrollBehavior = scrollBehaviorFactory()
927 })
928 }
929 }
930 }
931
isOppositeValueAfterScrollnull932 private fun isOppositeValueAfterScroll(delta: Float): Boolean =
933 sign(rotaryScrollDistance) * sign(delta) == -1f &&
934 (abs(delta) < abs(rotaryScrollDistance))
935
936 private fun isNewScrollEvent(timestamp: Long): Boolean {
937 val timeDelta = timestamp - previousScrollEventTime
938 return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
939 }
940
resetTrackingnull941 private fun resetTracking(timestamp: Long) {
942 scrollBehavior = scrollBehaviorFactory()
943 rotaryFlingBehavior = rotaryFlingBehaviorFactory()
944 rotaryFlingBehavior?.startFlingTracking(timestamp)
945 }
946 }
947
948 /**
949 * A scroll handler for Bezel(low-res) without snapping.
950 * This scroll handler supports fling. It can be set with RotaryFlingBehavior.
951 */
952 internal class LowResRotaryScrollHandler(
953 private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?,
954 private val scrollBehaviorFactory: () -> RotaryScrollBehavior,
955 ) : RotaryScrollHandler {
956
957 private val gestureThresholdTime = 200L
958 private var previousScrollEventTime = 0L
959 private var rotaryScrollDistance = 0f
960
961 private var scrollJob: Job = CompletableDeferred<Unit>()
962 private var flingJob: Job = CompletableDeferred<Unit>()
963
964 private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory()
965 private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory()
966
handleScrollEventnull967 override suspend fun handleScrollEvent(
968 coroutineScope: CoroutineScope,
969 event: TimestampedDelta,
970 rotaryHaptics: RotaryHapticHandler,
971 ) {
972 val time = event.timestamp
973
974 if (isNewScrollEvent(time)) {
975 resetTracking(time)
976 rotaryScrollDistance = event.delta
977 } else {
978 rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta)
979 rotaryScrollDistance += event.delta
980 }
981
982 scrollJob.cancel()
983 flingJob.cancel()
984
985 rotaryHaptics.handleScrollHaptic(event.delta)
986 debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
987
988 previousScrollEventTime = time
989 scrollJob = coroutineScope.async {
990 scrollBehavior.handleEvent(rotaryScrollDistance)
991 }
992
993 flingJob = coroutineScope.async {
994 rotaryFlingBehavior?.trackFling(
995 beforeFling = {
996 debugLog { "Calling before fling section" }
997 scrollJob.cancel()
998 scrollBehavior = scrollBehaviorFactory()
999 },
1000 )
1001 }
1002 }
1003
isNewScrollEventnull1004 private fun isNewScrollEvent(timestamp: Long): Boolean {
1005 val timeDelta = timestamp - previousScrollEventTime
1006 return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
1007 }
1008
resetTrackingnull1009 private fun resetTracking(timestamp: Long) {
1010 scrollBehavior = scrollBehaviorFactory()
1011 debugLog { "Velocity tracker reset" }
1012 rotaryFlingBehavior = rotaryFlingBehaviorFactory()
1013 rotaryFlingBehavior?.startFlingTracking(timestamp)
1014 }
1015 }
1016
1017 /**
1018 * A scroll handler for RSB(high-res) with snapping and without fling
1019 * Snapping happens after a threshold is reached ( set in [RotarySnapBehavior])
1020 *
1021 * This scroll handler doesn't support fling.
1022 */
1023 internal class HighResSnapHandler(
1024 private val resistanceFactor: Float,
1025 private val thresholdBehaviorFactory: () -> ThresholdBehavior,
1026 private val snapBehaviourFactory: () -> RotarySnapBehavior,
1027 private val scrollBehaviourFactory: () -> RotaryScrollBehavior,
1028 ) : RotaryScrollHandler {
1029 private val gestureThresholdTime = 200L
1030 private val snapDelay = 100L
1031 private val maxSnapsPerEvent = 2
1032
1033 private var scrollJob: Job = CompletableDeferred<Unit>()
1034 private var snapJob: Job = CompletableDeferred<Unit>()
1035
1036 private var previousScrollEventTime = 0L
1037 private var snapAccumulator = 0f
1038 private var rotaryScrollDistance = 0f
1039 private var scrollInProgress = false
1040
1041 private var snapBehaviour = snapBehaviourFactory()
1042 private var scrollBehaviour = scrollBehaviourFactory()
1043 private var thresholdBehavior = thresholdBehaviorFactory()
1044
1045 private val scrollEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f)
1046
handleScrollEventnull1047 override suspend fun handleScrollEvent(
1048 coroutineScope: CoroutineScope,
1049 event: TimestampedDelta,
1050 rotaryHaptics: RotaryHapticHandler,
1051 ) {
1052 val time = event.timestamp
1053
1054 if (isNewScrollEvent(time)) {
1055 debugLog { "New scroll event" }
1056 resetTracking()
1057 snapJob.cancel()
1058 snapBehaviour = snapBehaviourFactory()
1059 scrollBehaviour = scrollBehaviourFactory()
1060 thresholdBehavior = thresholdBehaviorFactory()
1061 thresholdBehavior.startThresholdTracking(time)
1062 snapAccumulator = 0f
1063 rotaryScrollDistance = 0f
1064 }
1065
1066 if (!isOppositeValueAfterScroll(event.delta)) {
1067 thresholdBehavior.observeEvent(event.timestamp, event.delta)
1068 } else {
1069 debugLog { "Opposite value after scroll :${event.delta}" }
1070 }
1071
1072 thresholdBehavior.applySmoothing()
1073 val snapThreshold = thresholdBehavior.snapThreshold()
1074
1075 snapAccumulator += event.delta
1076 if (!snapJob.isActive) {
1077 val resistanceCoeff =
1078 1 - scrollEasing.transform(rotaryScrollDistance.absoluteValue / snapThreshold)
1079 rotaryScrollDistance += event.delta * resistanceCoeff
1080 }
1081
1082 debugLog { "Snap accumulator: $snapAccumulator" }
1083 debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
1084
1085 debugLog { "snapThreshold: $snapThreshold" }
1086 previousScrollEventTime = time
1087
1088 if (abs(snapAccumulator) > snapThreshold) {
1089 scrollInProgress = false
1090 scrollBehaviour = scrollBehaviourFactory()
1091 scrollJob.cancel()
1092
1093 val snapDistance = (snapAccumulator / snapThreshold).toInt()
1094 .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent)
1095 snapAccumulator -= snapThreshold * snapDistance
1096 val sequentialSnap = snapJob.isActive
1097
1098 debugLog {
1099 "Snap threshold reached: snapDistance:$snapDistance, " +
1100 "sequentialSnap: $sequentialSnap, " +
1101 "snap accumulator remaining: $snapAccumulator"
1102 }
1103 if ((!snapBehaviour.topEdgeReached() && snapDistance < 0) ||
1104 (!snapBehaviour.bottomEdgeReached() && snapDistance > 0)
1105 ) {
1106 rotaryHaptics.handleSnapHaptic(event.delta)
1107 }
1108
1109 snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap)
1110 if (!snapJob.isActive) {
1111 snapJob.cancel()
1112 snapJob = coroutineScope.async {
1113 debugLog { "Snap started" }
1114 try {
1115 snapBehaviour.snapToTargetItem()
1116 } finally {
1117 debugLog { "Snap called finally" }
1118 }
1119 }
1120 }
1121 rotaryScrollDistance = 0f
1122 } else {
1123 if (!snapJob.isActive) {
1124 scrollJob.cancel()
1125 debugLog { "Scrolling for $rotaryScrollDistance/$resistanceFactor px" }
1126 scrollJob = coroutineScope.async {
1127 scrollBehaviour.handleEvent(rotaryScrollDistance / resistanceFactor)
1128 }
1129 delay(snapDelay)
1130 scrollInProgress = false
1131 scrollBehaviour = scrollBehaviourFactory()
1132 rotaryScrollDistance = 0f
1133 snapAccumulator = 0f
1134 snapBehaviour.prepareSnapForItems(0, false)
1135
1136 snapJob.cancel()
1137 snapJob = coroutineScope.async {
1138 snapBehaviour.snapToClosestItem()
1139 }
1140 }
1141 }
1142 }
1143
isOppositeValueAfterScrollnull1144 private fun isOppositeValueAfterScroll(delta: Float): Boolean =
1145 sign(rotaryScrollDistance) * sign(delta) == -1f &&
1146 (abs(delta) < abs(rotaryScrollDistance))
1147
1148 private fun isNewScrollEvent(timestamp: Long): Boolean {
1149 val timeDelta = timestamp - previousScrollEventTime
1150 return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
1151 }
1152
resetTrackingnull1153 private fun resetTracking() {
1154 scrollInProgress = true
1155 }
1156 }
1157
1158 /**
1159 * A scroll handler for RSB(high-res) with snapping and without fling
1160 * Snapping happens after a threshold is reached ( set in [RotarySnapBehavior])
1161 *
1162 * This scroll handler doesn't support fling.
1163 */
1164 internal class LowResSnapHandler(
1165 private val snapBehaviourFactory: () -> RotarySnapBehavior,
1166 ) : RotaryScrollHandler {
1167 private val gestureThresholdTime = 200L
1168
1169 private var snapJob: Job = CompletableDeferred<Unit>()
1170
1171 private var previousScrollEventTime = 0L
1172 private var snapAccumulator = 0f
1173 private var scrollInProgress = false
1174
1175 private var snapBehaviour = snapBehaviourFactory()
1176
handleScrollEventnull1177 override suspend fun handleScrollEvent(
1178 coroutineScope: CoroutineScope,
1179 event: TimestampedDelta,
1180 rotaryHaptics: RotaryHapticHandler,
1181 ) {
1182 val time = event.timestamp
1183
1184 if (isNewScrollEvent(time)) {
1185 debugLog { "New scroll event" }
1186 resetTracking()
1187 snapJob.cancel()
1188 snapBehaviour = snapBehaviourFactory()
1189 snapAccumulator = 0f
1190 }
1191
1192 snapAccumulator += event.delta
1193
1194 debugLog { "Snap accumulator: $snapAccumulator" }
1195
1196 previousScrollEventTime = time
1197
1198 if (abs(snapAccumulator) > 1f) {
1199 scrollInProgress = false
1200
1201 val snapDistance = sign(snapAccumulator).toInt()
1202 rotaryHaptics.handleSnapHaptic(event.delta)
1203 val sequentialSnap = snapJob.isActive
1204 debugLog {
1205 "Snap threshold reached: snapDistance:$snapDistance, " +
1206 "sequentialSnap: $sequentialSnap, " +
1207 "snap accumulator: $snapAccumulator"
1208 }
1209
1210 snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap)
1211 if (!snapJob.isActive) {
1212 snapJob.cancel()
1213 snapJob = coroutineScope.async {
1214 debugLog { "Snap started" }
1215 try {
1216 snapBehaviour.snapToTargetItem()
1217 } finally {
1218 debugLog { "Snap called finally" }
1219 }
1220 }
1221 }
1222 snapAccumulator = 0f
1223 }
1224 }
1225
isNewScrollEventnull1226 private fun isNewScrollEvent(timestamp: Long): Boolean {
1227 val timeDelta = timestamp - previousScrollEventTime
1228 return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
1229 }
1230
resetTrackingnull1231 private fun resetTracking() {
1232 scrollInProgress = true
1233 }
1234 }
1235
1236 internal class ThresholdBehavior(
1237 private val rotaryScrollAdapter: RotaryScrollAdapter,
1238 private val thresholdDivider: Float,
1239 private val minVelocity: Float = 300f,
1240 private val maxVelocity: Float = 3000f,
1241 private val smoothingConstant: Float = 0.4f,
1242 ) {
1243 private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f)
1244
1245 private val rotaryVelocityTracker = RotaryVelocityTracker()
1246
1247 private var smoothedVelocity = 0f
startThresholdTrackingnull1248 fun startThresholdTracking(time: Long) {
1249 rotaryVelocityTracker.start(time)
1250 smoothedVelocity = 0f
1251 }
1252
observeEventnull1253 fun observeEvent(timestamp: Long, delta: Float) {
1254 rotaryVelocityTracker.move(timestamp, delta)
1255 }
1256
applySmoothingnull1257 fun applySmoothing() {
1258 if (rotaryVelocityTracker.velocity != 0.0f) {
1259 // smooth the velocity
1260 smoothedVelocity = exponentialSmoothing(
1261 currentVelocity = rotaryVelocityTracker.velocity.absoluteValue,
1262 prevVelocity = smoothedVelocity,
1263 smoothingConstant = smoothingConstant,
1264 )
1265 }
1266 debugLog { "rotaryVelocityTracker velocity: ${rotaryVelocityTracker.velocity}" }
1267 debugLog { "SmoothedVelocity: $smoothedVelocity" }
1268 }
1269
snapThresholdnull1270 fun snapThreshold(): Float {
1271 val thresholdDividerFraction =
1272 thresholdDividerEasing.transform(
1273 inverseLerp(
1274 minVelocity,
1275 maxVelocity,
1276 smoothedVelocity,
1277 ),
1278 )
1279 return rotaryScrollAdapter.averageItemSize() / lerp(
1280 1f,
1281 thresholdDivider,
1282 thresholdDividerFraction,
1283 )
1284 }
1285
exponentialSmoothingnull1286 private fun exponentialSmoothing(
1287 currentVelocity: Float,
1288 prevVelocity: Float,
1289 smoothingConstant: Float,
1290 ): Float =
1291 smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity
1292 }
1293
1294 @Composable
1295 private fun rememberTimestampChannel() = remember {
1296 Channel<TimestampedDelta>(capacity = Channel.CONFLATED)
1297 }
1298
inverseLerpnull1299 private fun inverseLerp(start: Float, stop: Float, value: Float): Float {
1300 return ((value - start) / (stop - start)).coerceIn(0f, 1f)
1301 }
1302