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