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