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