/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.wear.compose.foundation.rotary
import android.view.ViewConfiguration
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.rotary.RotaryInputModifierNode
import androidx.compose.ui.input.rotary.RotaryScrollEvent
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.util.fastSumBy
import androidx.compose.ui.util.lerp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.foundation.lazy.inverseLerp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
/**
* A modifier which connects rotary events with scrollable containers such as Column,
* LazyList and others. [ScalingLazyColumn] has a build-in rotary support, and accepts
* [rotaryBehavior] parameter directly.
*
* This modifier supports rotary scrolling and snapping.
* The behaviour is configured by the provided [RotaryBehavior]:
* either provide [RotaryDefaults.scrollBehavior] for scrolling with/without fling
* or pass [RotaryDefaults.snapBehavior] when snap is required.
*
* Example of scrolling with fling:
* @sample androidx.wear.compose.foundation.samples.RotaryScrollSample
*
* Example of scrolling with snap:
* @sample androidx.wear.compose.foundation.samples.RotarySnapSample
*
* @param rotaryBehavior Specified [RotaryBehavior] for rotary handling with snap or fling.
* @param focusRequester Used to request the focus for rotary input. Each composable with this
* modifier should have a separate focusRequester, and only one of them at a time can be active.
* @param reverseDirection Reverse the direction of scrolling if required for consistency
* with the scrollable state passed via [rotaryBehavior].
*/
fun Modifier.rotary(
rotaryBehavior: RotaryBehavior,
focusRequester: FocusRequester,
reverseDirection: Boolean = false
): Modifier =
rotaryHandler(
rotaryBehavior = rotaryBehavior,
reverseDirection = reverseDirection,
)
.focusRequester(focusRequester)
.focusable()
/**
* An interface for handling scroll events. Has implementations for handling scroll
* with/without fling [RotaryScrollBehavior] and for handling snap [LowResRotarySnapBehavior],
* [HighResRotarySnapBehavior].
*/
interface RotaryBehavior {
/**
* Handles scrolling events.
*
* @param timestamp The time in milliseconds at which this even occurred
* @param deltaInPixels The amount to scroll (in pixels)
* @param deviceId The id for the input device that this event came from
* @param orientation Orientation of the scrolling
*/
suspend fun CoroutineScope.handleScrollEvent(
timestamp: Long,
deltaInPixels: Float,
deviceId: Int,
orientation: Orientation
)
}
/**
* An adapter which connects scrollableState to a rotary input for snapping scroll actions.
*
* This interface defines the essential properties and methods required for a scrollable
* to be controlled by rotary input and perform a snap action.
*
*/
interface RotaryScrollableAdapter {
/**
* The scrollable state used for performing scroll actions in response to rotary events.
*/
val scrollableState: ScrollableState
/**
* Calculates the average size of an item within the scrollable. This is used to
* estimate scrolling distances for snapping when responding to rotary input.
*
* @return The average item size in pixels.
*/
fun averageItemSize(): Float
/**
* Returns the index of the item that is closest to the center.
*/
fun currentItemIndex(): Int
/**
* Returns the offset of the currently centered item from its centered position.
* This value can be positive or negative.
*
* @return The offset of the current item in pixels.
*/
fun currentItemOffset(): Float
/**
* The total number of items within the scrollable in [scrollableState]
*/
fun totalItemsCount(): Int
}
/**
* Defaults for rotary modifiers
*/
object RotaryDefaults {
/**
* Implementation of [RotaryBehavior] to define scrolling behaviour with or without fling -
* used with the [rotary] modifier when snapping is not required.
*
* If fling is not required, set [flingBehavior] = null. In that case,
* flinging will not happen and the scrollable content will
* stop scrolling immediately after the user stops interacting with rotary input.
*
* @param scrollableState Scrollable state which will be scrolled
* while receiving rotary events.
* @param flingBehavior Optional rotary fling behavior, pass null to
* turn off fling if necessary.
* @param hapticFeedbackEnabled Controls whether haptic feedback is given during rotary
* scrolling (true by default). It's recommended to keep the default value of true
* for premium scrolling experience.
*/
@Composable
fun scrollBehavior(
scrollableState: ScrollableState,
flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(),
hapticFeedbackEnabled: Boolean = true
): RotaryBehavior {
val isLowRes = isLowResInput()
val viewConfiguration = ViewConfiguration.get(LocalContext.current)
val rotaryHaptics: RotaryHapticHandler =
rememberRotaryHapticHandler(scrollableState, hapticFeedbackEnabled)
return flingBehavior(
scrollableState,
rotaryHaptics,
flingBehavior,
isLowRes,
viewConfiguration
)
}
/**
* Implementation of [RotaryBehavior] to define scrolling behaviour with snap -
* used with the [rotary] modifier when snapping is required.
*
* @param rotaryScrollableAdapter A connection between scrollable entities and rotary events.
* @param snapOffset An optional offset to be applied when snapping the item.
* After snapping, defines the offset to the center of the item.
* @param hapticFeedbackEnabled Controls whether haptic feedback is given during
* rotary scrolling (true by default). It's recommended to keep the default value of true
* for premium scrolling experience.
*/
@Composable
fun snapBehavior(
rotaryScrollableAdapter: RotaryScrollableAdapter,
snapOffset: Int = SnapOffset,
hapticFeedbackEnabled: Boolean = true
): RotaryBehavior {
val isLowRes = isLowResInput()
val rotaryHaptics: RotaryHapticHandler =
rememberRotaryHapticHandler(
rotaryScrollableAdapter.scrollableState,
hapticFeedbackEnabled
)
return remember(rotaryScrollableAdapter, rotaryHaptics, snapOffset, isLowRes) {
snapBehavior(
rotaryScrollableAdapter,
rotaryHaptics,
snapOffset,
ThresholdDivider,
ResistanceFactor,
isLowRes
)
}
}
/**
* Implementation of [RotaryBehavior] to define scrolling behaviour with snap for
* [ScalingLazyColumn] - used with the [rotary] modifier when snapping is required.
*
* @param state [ScalingLazyListState] to which rotary scroll will be connected.
* @param snapOffset An optional offset to be applied when snapping the item.
* After snapping, defines the offset to the center of the item.
* @param hapticFeedbackEnabled Controls whether haptic feedback is given during
* rotary scrolling (true by default). It's recommended to keep the default value of true
* for premium scrolling experience.
*/
@Composable
fun snapBehavior(
state: ScalingLazyListState,
snapOffset: Int = SnapOffset,
hapticFeedbackEnabled: Boolean = true
): RotaryBehavior = snapBehavior(
rotaryScrollableAdapter = remember(state) {
ScalingLazyColumnRotaryScrollableAdapter(state)
},
snapOffset = snapOffset,
hapticFeedbackEnabled = hapticFeedbackEnabled
)
/**
* Returns whether the input is Low-res (a bezel) or high-res (a crown/rsb).
*/
@Composable
private fun isLowResInput(): Boolean = LocalContext.current.packageManager
.hasSystemFeature("android.hardware.rotaryencoder.lowres")
private const val SnapOffset: Int = 0
private const val ThresholdDivider: Float = 1.5f
private const val ResistanceFactor: Float = 3f
// These values represent the timeframe for a fling event. A bigger value is assigned
// to low-res input due to the lower frequency of low-res rotary events.
internal const val LowResFlingTimeframe: Long = 100L
internal const val HighResFlingTimeframe: Long = 30L
}
/**
* An implementation of rotary scroll adapter for ScalingLazyColumn
*/
internal class ScalingLazyColumnRotaryScrollableAdapter(
override val scrollableState: ScalingLazyListState
) : RotaryScrollableAdapter {
/**
* Calculates the average item height by averaging the height of visible items.
*/
override fun averageItemSize(): Float {
val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
}
/**
* Current (centered) item index
*/
override fun currentItemIndex(): Int = scrollableState.centerItemIndex
/**
* The offset from the item center.
*/
override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat()
/**
* The total count of items in ScalingLazyColumn
*/
override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount
}
/**
* Handles scroll with fling.
*
* @return A scroll with fling implementation of [RotaryBehavior] which is suitable
* for both low-res and high-res inputs.
*
* @param scrollableState Scrollable state which will be scrolled while receiving rotary events
* @param flingBehavior Logic describing Fling behavior. If null - fling will not happen
* @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
* @param viewConfiguration [ViewConfiguration] for accessing default fling values
*/
private fun flingBehavior(
scrollableState: ScrollableState,
rotaryHaptics: RotaryHapticHandler,
flingBehavior: FlingBehavior? = null,
isLowRes: Boolean,
viewConfiguration: ViewConfiguration
): RotaryBehavior {
fun rotaryFlingHandler() = flingBehavior?.run {
RotaryFlingHandler(
scrollableState,
flingBehavior,
viewConfiguration,
flingTimeframe = if (isLowRes) RotaryDefaults.LowResFlingTimeframe
else RotaryDefaults.HighResFlingTimeframe
)
}
fun scrollHandler() = RotaryScrollHandler(scrollableState)
return RotaryScrollBehavior(
isLowRes,
rotaryHaptics,
rotaryFlingHandlerFactory = { rotaryFlingHandler() },
scrollHandlerFactory = { scrollHandler() }
)
}
/**
* Handles scroll with snap.
*
* @return A snap implementation of [RotaryBehavior] which is either suitable for low-res or
* high-res input.
*
* @param rotaryScrollableAdapter Implementation of [RotaryScrollableAdapter], which connects
* scrollableState to a rotary input for snapping scroll actions.
* @param rotaryHaptics Implementation of [RotaryHapticHandler] which handles haptics
* for rotary usage
* @param snapOffset An offset to be applied when snapping the item. After the snap the
* snapped items offset will be [snapOffset].
* @param maxThresholdDivider Factor to divide item size when calculating threshold.
* @param scrollDistanceDivider A value which is used to slow down or
* speed up the scroll before snap happens. The higher the value the slower the scroll.
* @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
*/
private fun snapBehavior(
rotaryScrollableAdapter: RotaryScrollableAdapter,
rotaryHaptics: RotaryHapticHandler,
snapOffset: Int,
maxThresholdDivider: Float,
scrollDistanceDivider: Float,
isLowRes: Boolean
): RotaryBehavior {
return if (isLowRes) {
LowResRotarySnapBehavior(
rotaryHaptics = rotaryHaptics,
snapHandlerFactory = {
RotarySnapHandler(
rotaryScrollableAdapter,
snapOffset,
)
}
)
} else {
HighResRotarySnapBehavior(
rotaryHaptics = rotaryHaptics,
scrollDistanceDivider = scrollDistanceDivider,
thresholdHandlerFactory = {
ThresholdHandler(
maxThresholdDivider,
averageItemSize = { rotaryScrollableAdapter.averageItemSize() }
)
},
snapHandlerFactory = {
RotarySnapHandler(
rotaryScrollableAdapter,
snapOffset,
)
},
scrollHandlerFactory = {
RotaryScrollHandler(rotaryScrollableAdapter.scrollableState)
}
)
}
}
/**
* An abstract base class for handling scroll events. Has implementations for handling scroll
* with/without fling [RotaryScrollBehavior] and for handling snap [LowResRotarySnapBehavior],
* [HighResRotarySnapBehavior].
*/
internal abstract class BaseRotaryBehavior : RotaryBehavior {
// Threshold for detection of a new gesture
private val gestureThresholdTime = 200L
protected var previousScrollEventTime = -1L
protected fun isNewScrollEvent(timestamp: Long): Boolean {
val timeDelta = timestamp - previousScrollEventTime
return previousScrollEventTime == -1L || timeDelta > gestureThresholdTime
}
}
/**
* This class does a smooth animation when the scroll by N pixels is done.
* This animation works well on Rsb(high-res) and Bezel(low-res) devices.
*/
internal class RotaryScrollHandler(
private val scrollableState: ScrollableState
) {
private var sequentialAnimation = false
private var scrollAnimation = AnimationState(0f)
private var prevPosition = 0f
private var scrollJob: Job = CompletableDeferred<Unit>()
/**
* Produces scroll to [targetValue]
*/
fun scrollToTarget(coroutineScope: CoroutineScope, targetValue: Float) {
cancelScrollIfActive()
scrollJob = coroutineScope.async {
scrollTo(targetValue)
}
}
fun cancelScrollIfActive() {
if (scrollJob.isActive) scrollJob.cancel()
}
private suspend fun scrollTo(targetValue: Float) {
scrollableState.scroll(MutatePriority.UserInput) {
debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" }
scrollAnimation.animateTo(
targetValue,
animationSpec = spring(),
sequentialAnimation = sequentialAnimation
) {
val delta = value - prevPosition
debugLog { "Animated by $delta, value: $value" }
scrollBy(delta)
prevPosition = value
sequentialAnimation = value != this.targetValue
}
}
}
}
/**
* A helper class for snapping with rotary.
*/
internal class RotarySnapHandler(
private val rotaryScrollableAdapter: RotaryScrollableAdapter,
private val snapOffset: Int,
) {
private var snapTarget: Int = rotaryScrollableAdapter.currentItemIndex()
private var sequentialSnap: Boolean = false
private var anim = AnimationState(0f)
private var expectedDistance = 0f
private val defaultStiffness = 200f
private var snapTargetUpdated = true
/**
* Updating snapping target. This method should be called before [snapToTargetItem].
*
* Snapping is done for current + [moveForElements] items.
*
* If [sequentialSnap] is true, items are summed up together.
* For example, if [updateSnapTarget] is called with
* [moveForElements] = 2, 3, 5 -> then the snapping will happen to current + 10 items
*
* If [sequentialSnap] is false, then [moveForElements] are not summed up together.
*/
fun updateSnapTarget(moveForElements: Int, sequentialSnap: Boolean) {
this.sequentialSnap = sequentialSnap
if (sequentialSnap) {
snapTarget += moveForElements
} else {
snapTarget = rotaryScrollableAdapter.currentItemIndex() + moveForElements
}
snapTargetUpdated = true
snapTarget = snapTarget
.coerceIn(0 until rotaryScrollableAdapter.totalItemsCount())
}
/**
* Performs snapping to the closest item.
*/
suspend fun snapToClosestItem() {
// Perform the snapping animation
rotaryScrollableAdapter.scrollableState.scroll(MutatePriority.UserInput) {
debugLog { "snap to the closest item" }
var prevPosition = 0f
// Create and execute the snap animation
AnimationState(0f).animateTo(
targetValue = -rotaryScrollableAdapter.currentItemOffset(),
animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing)
) {
val animDelta = value - prevPosition
scrollBy(animDelta)
prevPosition = value
}
// Update the snap target to ensure consistency
snapTarget = rotaryScrollableAdapter.currentItemIndex()
}
}
/**
* Returns true if top edge was reached
*/
fun topEdgeReached(): Boolean = snapTarget <= 0
/**
* Returns true if bottom edge was reached
*/
fun bottomEdgeReached(): Boolean =
snapTarget >= rotaryScrollableAdapter.totalItemsCount() - 1
/**
* Performs snapping to the specified in [updateSnapTarget] element
*/
suspend fun snapToTargetItem() {
if (!sequentialSnap) anim = AnimationState(0f)
rotaryScrollableAdapter.scrollableState.scroll(MutatePriority.UserInput) {
// If snapTargetUpdated is true -means the target was updated so we
// need to do snap animation again
while (snapTargetUpdated) {
snapTargetUpdated = false
var latestCenterItem: Int
var continueFirstScroll = true
debugLog { "snapTarget $snapTarget" }
// First part of animation. Performing it until the target element centered.
while (continueFirstScroll) {
latestCenterItem = rotaryScrollableAdapter.currentItemIndex()
expectedDistance = expectedDistanceTo(snapTarget, snapOffset)
debugLog {
"expectedDistance = $expectedDistance, " +
"scrollableState.centerItemScrollOffset " +
"${rotaryScrollableAdapter.currentItemOffset()}"
}
continueFirstScroll = false
var prevPosition = anim.value
anim.animateTo(
prevPosition + expectedDistance,
animationSpec = spring(
stiffness = defaultStiffness,
visibilityThreshold = 0.1f
),
sequentialAnimation = (anim.velocity != 0f)
) {
// Exit animation if snap target was updated
if (snapTargetUpdated) cancelAnimation()
val animDelta = value - prevPosition
debugLog {
"First animation, value:$value, velocity:$velocity, " +
"animDelta:$animDelta"
}
scrollBy(animDelta)
prevPosition = value
if (latestCenterItem != rotaryScrollableAdapter.currentItemIndex()) {
continueFirstScroll = true
cancelAnimation()
return@animateTo
}
debugLog {
"centerItemIndex = ${rotaryScrollableAdapter.currentItemIndex()}"
}
if (rotaryScrollableAdapter.currentItemIndex() == snapTarget) {
debugLog { "Target is near the centre. Cancelling first animation" }
debugLog {
"scrollableState.centerItemScrollOffset " +
"${rotaryScrollableAdapter.currentItemOffset()}"
}
expectedDistance = -rotaryScrollableAdapter.currentItemOffset()
continueFirstScroll = false
cancelAnimation()
return@animateTo
}
}
}
// Exit animation if snap target was updated
if (snapTargetUpdated) continue
// Second part of Animation - animating to the centre of target element.
var prevPosition = anim.value
anim.animateTo(
prevPosition + expectedDistance,
animationSpec = SpringSpec(
stiffness = defaultStiffness,
visibilityThreshold = 0.1f
),
sequentialAnimation = (anim.velocity != 0f)
) {
// Exit animation if snap target was updated
if (snapTargetUpdated) cancelAnimation()
val animDelta = value - prevPosition
debugLog { "Final animation. velocity:$velocity, animDelta:$animDelta" }
scrollBy(animDelta)
prevPosition = value
}
}
}
}
private fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
val averageSize = rotaryScrollableAdapter.averageItemSize()
val indexesDiff = index - rotaryScrollableAdapter.currentItemIndex()
debugLog { "Average size $averageSize" }
return (averageSize * indexesDiff) +
targetScrollOffset - rotaryScrollableAdapter.currentItemOffset()
}
}
/**
* A modifier which handles rotary events.
* It accepts [RotaryBehavior] as the input - a class that handles the main scroll logic.
*/
internal fun Modifier.rotaryHandler(
rotaryBehavior: RotaryBehavior,
reverseDirection: Boolean,
inspectorInfo: InspectorInfo.() -> Unit = debugInspectorInfo {
name = "rotaryHandler"
properties["rotaryBehavior"] = rotaryBehavior
properties["reverseDirection"] = reverseDirection
}
): Modifier = this then RotaryHandlerElement(
rotaryBehavior,
reverseDirection,
inspectorInfo
)
/**
* Class responsible for Fling behaviour with rotary.
* It tracks rotary events and produces fling when necessary.
* @param flingTimeframe represents a time interval (in milliseconds) used to determine
* whether a rotary input should trigger a fling. If no new events come during this interval,
* then the fling is triggered.
*/
internal class RotaryFlingHandler(
private val scrollableState: ScrollableState,
private val flingBehavior: FlingBehavior,
viewConfiguration: ViewConfiguration,
private val flingTimeframe: Long
) {
private var flingJob: Job = CompletableDeferred<Unit>()
// A time range during which the fling is valid.
// For simplicity it's twice as long as [flingTimeframe]
private val timeRangeToFling = flingTimeframe * 2
// A default fling factor for making fling slower
private val flingScaleFactor = 0.7f
private var previousVelocity = 0f
private val rotaryVelocityTracker = RotaryVelocityTracker()
private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat()
private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat()
private var latestEventTimestamp: Long = 0
private var flingVelocity: Float = 0f
private var flingTimestamp: Long = 0
/**
* Starts a new fling tracking session
* with specified timestamp
*/
fun startFlingTracking(timestamp: Long) {
rotaryVelocityTracker.start(timestamp)
latestEventTimestamp = timestamp
previousVelocity = 0f
}
fun cancelFlingIfActive() {
if (flingJob.isActive) flingJob.cancel()
}
/**
* Observing new event within a fling tracking session with new timestamp and delta
*/
fun observeEvent(timestamp: Long, delta: Float) {
rotaryVelocityTracker.move(timestamp, delta)
latestEventTimestamp = timestamp
}
fun performFlingIfRequired(
coroutineScope: CoroutineScope,
beforeFling: () -> Unit,
edgeReached: (velocity: Float) -> Unit
) {
cancelFlingIfActive()
flingJob = coroutineScope.async {
trackFling(beforeFling, edgeReached)
}
}
/**
* Performing fling if necessary and calling [beforeFling] lambda before it is triggered.
* [edgeReached] is called when the scroll reaches the end of the list and can't scroll further
*/
private suspend fun trackFling(
beforeFling: () -> Unit,
edgeReached: (velocity: Float) -> Unit
) {
val currentVelocity = rotaryVelocityTracker.velocity
debugLog { "currentVelocity: $currentVelocity" }
if (abs(currentVelocity) >= abs(previousVelocity)) {
flingTimestamp = latestEventTimestamp
flingVelocity = currentVelocity * flingScaleFactor
}
previousVelocity = currentVelocity
// Waiting for a fixed amount of time before checking the fling
delay(flingTimeframe)
// For making a fling 2 criteria should be met:
// 1) no more than
// `timeRangeToFling` ms should pass between last fling detection
// and the time of last motion event
// 2) flingVelocity should exceed the minFlingSpeed
debugLog {
"Check fling: flingVelocity: $flingVelocity " +
"minFlingSpeed: $minFlingSpeed, maxFlingSpeed: $maxFlingSpeed"
}
if (latestEventTimestamp - flingTimestamp < timeRangeToFling &&
abs(flingVelocity) > minFlingSpeed
) {
// Call beforeFling because a fling will be performed
beforeFling()
val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed)
scrollableState.scroll(MutatePriority.UserInput) {
with(flingBehavior) {
debugLog { "Flinging with velocity $velocity" }
val remainedVelocity = performFling(velocity)
debugLog { "-- Velocity after fling: $remainedVelocity" }
if (remainedVelocity != 0.0f) {
edgeReached(remainedVelocity)
}
}
}
}
}
}
/**
* A scroll behavior for scrolling without snapping and with or without fling.
* A list is scrolled by the number of pixels received from the rotary device.
*
* For a high-res input it has a filtering for events which are coming
* with an opposite sign (this might happen to devices with rsb,
* especially at the end of the scroll )
*
* This scroll behavior supports fling. It can be set with [RotaryFlingHandler].
*/
internal class RotaryScrollBehavior(
private val isLowRes: Boolean,
private val rotaryHaptics: RotaryHapticHandler,
private val rotaryFlingHandlerFactory: () -> RotaryFlingHandler?,
private val scrollHandlerFactory: () -> RotaryScrollHandler,
) : BaseRotaryBehavior() {
private var rotaryScrollDistance = 0f
private var rotaryFlingHandler: RotaryFlingHandler? = rotaryFlingHandlerFactory()
private var scrollHandler: RotaryScrollHandler = scrollHandlerFactory()
override suspend fun CoroutineScope.handleScrollEvent(
timestamp: Long,
deltaInPixels: Float,
deviceId: Int,
orientation: Orientation
) {
debugLog { "RotaryScrollHandler: handleScrollEvent" }
if (isNewScrollEvent(timestamp)) {
debugLog { "New scroll event" }
resetScrolling()
resetFlingTracking(timestamp)
} else {
// Due to the physics of high-res Rotary side button, some events might come
// with an opposite axis value - either at the start or at the end of the motion.
// We don't want to use these values for fling calculations.
if (isLowRes || !isOppositeValueAfterScroll(deltaInPixels)) {
rotaryFlingHandler?.observeEvent(timestamp, deltaInPixels)
} else {
debugLog { "Opposite value after scroll :$deltaInPixels" }
}
}
rotaryScrollDistance += deltaInPixels
debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
rotaryHaptics.handleScrollHaptic(timestamp, deltaInPixels)
previousScrollEventTime = timestamp
scrollHandler.scrollToTarget(this, rotaryScrollDistance)
rotaryFlingHandler?.performFlingIfRequired(
this,
beforeFling = {
debugLog { "Calling beforeFling section" }
resetScrolling()
},
edgeReached = { velocity ->
rotaryHaptics.handleLimitHaptic(velocity > 0f)
}
)
}
private fun resetScrolling() {
scrollHandler.cancelScrollIfActive()
scrollHandler = scrollHandlerFactory()
rotaryScrollDistance = 0f
}
private fun resetFlingTracking(timestamp: Long) {
rotaryFlingHandler?.cancelFlingIfActive()
rotaryFlingHandler = rotaryFlingHandlerFactory()
rotaryFlingHandler?.startFlingTracking(timestamp)
}
private fun isOppositeValueAfterScroll(delta: Float): Boolean =
rotaryScrollDistance * delta < 0f &&
(abs(delta) < abs(rotaryScrollDistance))
}
/**
* A scroll behavior for RSB(high-res) input with snapping and without fling.
*
* Threshold for snapping is set dynamically in ThresholdBehavior, which depends
* on the scroll speed and the average size of the items.
*
* This scroll handler doesn't support fling.
*/
internal class HighResRotarySnapBehavior(
private val rotaryHaptics: RotaryHapticHandler,
private val scrollDistanceDivider: Float,
private val thresholdHandlerFactory: () -> ThresholdHandler,
private val snapHandlerFactory: () -> RotarySnapHandler,
private val scrollHandlerFactory: () -> RotaryScrollHandler
) : BaseRotaryBehavior() {
private val snapDelay = 100L
// This parameter limits number of snaps which can happen during single event.
private val maxSnapsPerEvent = 2
private var snapJob: Job = CompletableDeferred<Unit>()
private var accumulatedSnapDelta = 0f
private var rotaryScrollDistance = 0f
private var snapHandler = snapHandlerFactory()
private var scrollHandler = scrollHandlerFactory()
private var thresholdHandler = thresholdHandlerFactory()
private val scrollProximityEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f)
override suspend fun CoroutineScope.handleScrollEvent(
timestamp: Long,
deltaInPixels: Float,
deviceId: Int,
orientation: Orientation
) {
debugLog { "HighResSnapHandler: handleScrollEvent" }
if (isNewScrollEvent(timestamp)) {
debugLog { "New scroll event" }
resetScrolling()
resetSnapping()
resetThresholdTracking(timestamp)
}
if (!isOppositeValueAfterScroll(deltaInPixels)) {
thresholdHandler.updateTracking(timestamp, deltaInPixels)
} else {
debugLog { "Opposite value after scroll :$deltaInPixels" }
}
val snapThreshold = thresholdHandler.calculateSnapThreshold()
debugLog { "snapThreshold: $snapThreshold" }
if (!snapJob.isActive) {
val proximityFactor = calculateProximityFactor(snapThreshold)
rotaryScrollDistance += deltaInPixels * proximityFactor
}
debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
accumulatedSnapDelta += deltaInPixels
debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }
previousScrollEventTime = timestamp
if (abs(accumulatedSnapDelta) > snapThreshold) {
resetScrolling()
// We limit a number of handled snap items per event to [maxSnapsPerEvent],
// as otherwise the snap might happen too quickly
val snapDistanceInItems = (accumulatedSnapDelta / snapThreshold).toInt()
.coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent)
accumulatedSnapDelta -= snapThreshold * snapDistanceInItems
//
val sequentialSnap = snapJob.isActive
debugLog {
"Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
"sequentialSnap: $sequentialSnap, " +
"Accumulated snap delta: $accumulatedSnapDelta"
}
if (edgeNotReached(snapDistanceInItems)) {
rotaryHaptics.handleSnapHaptic(timestamp, deltaInPixels)
}
snapHandler.updateSnapTarget(snapDistanceInItems, sequentialSnap)
if (!snapJob.isActive) {
snapJob.cancel()
snapJob = with(this) {
async {
debugLog { "Snap started" }
try {
snapHandler.snapToTargetItem()
} finally {
debugLog { "Snap called finally" }
}
}
}
}
rotaryScrollDistance = 0f
} else {
if (!snapJob.isActive) {
val distanceWithDivider = rotaryScrollDistance / scrollDistanceDivider
debugLog { "Scrolling for $distanceWithDivider px" }
scrollHandler.scrollToTarget(this, distanceWithDivider)
delay(snapDelay)
resetScrolling()
accumulatedSnapDelta = 0f
snapHandler.updateSnapTarget(0, false)
snapJob.cancel()
snapJob = with(this) {
async {
snapHandler.snapToClosestItem()
}
}
}
}
}
/**
* Calculates a value based on the rotaryScrollDistance and size of snapThreshold.
* The closer rotaryScrollDistance to snapThreshold, the lower the value.
*/
private fun calculateProximityFactor(snapThreshold: Float): Float =
1 - scrollProximityEasing
.transform(rotaryScrollDistance.absoluteValue / snapThreshold)
private fun edgeNotReached(snapDistanceInItems: Int): Boolean =
(!snapHandler.topEdgeReached() && snapDistanceInItems < 0) ||
(!snapHandler.bottomEdgeReached() && snapDistanceInItems > 0)
private fun resetScrolling() {
scrollHandler.cancelScrollIfActive()
scrollHandler = scrollHandlerFactory()
rotaryScrollDistance = 0f
}
private fun resetSnapping() {
snapJob.cancel()
snapHandler = snapHandlerFactory()
accumulatedSnapDelta = 0f
}
private fun resetThresholdTracking(time: Long) {
thresholdHandler = thresholdHandlerFactory()
thresholdHandler.startThresholdTracking(time)
}
private fun isOppositeValueAfterScroll(delta: Float): Boolean =
rotaryScrollDistance * delta < 0f &&
(abs(delta) < abs(rotaryScrollDistance))
}
/**
* A scroll behavior for Bezel(low-res) input with snapping and without fling
*
* This scroll behavior doesn't support fling.
*/
internal class LowResRotarySnapBehavior(
private val rotaryHaptics: RotaryHapticHandler,
private val snapHandlerFactory: () -> RotarySnapHandler
) : BaseRotaryBehavior() {
private var snapJob: Job = CompletableDeferred<Unit>()
private var accumulatedSnapDelta = 0f
private var snapHandler = snapHandlerFactory()
override suspend fun CoroutineScope.handleScrollEvent(
timestamp: Long,
deltaInPixels: Float,
deviceId: Int,
orientation: Orientation
) {
debugLog { "LowResSnapHandler: handleScrollEvent" }
if (isNewScrollEvent(timestamp)) {
debugLog { "New scroll event" }
resetSnapping()
}
accumulatedSnapDelta += deltaInPixels
debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }
previousScrollEventTime = timestamp
if (abs(accumulatedSnapDelta) > 1f) {
val snapDistanceInItems = sign(accumulatedSnapDelta).toInt()
rotaryHaptics.handleSnapHaptic(timestamp, deltaInPixels)
val sequentialSnap = snapJob.isActive
debugLog {
"Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
"sequentialSnap: $sequentialSnap, " +
"Accumulated snap delta: $accumulatedSnapDelta"
}
snapHandler.updateSnapTarget(snapDistanceInItems, sequentialSnap)
if (!snapJob.isActive) {
snapJob.cancel()
snapJob = with(this) {
async {
debugLog { "Snap started" }
try {
snapHandler.snapToTargetItem()
} finally {
debugLog { "Snap called finally" }
}
}
}
}
accumulatedSnapDelta = 0f
}
}
private fun resetSnapping() {
snapJob.cancel()
snapHandler = snapHandlerFactory()
accumulatedSnapDelta = 0f
}
}
/**
* This class is responsible for determining the dynamic 'snapping' threshold.
* The threshold dictates how much rotary input is required to trigger a snapping action.
*
* The threshold is calculated dynamically based on the user's scroll input velocity.
* Faster scrolling results in a lower threshold, making snapping easier to achieve.
* An exponential smoothing is also applied to the velocity readings to reduce noise
* and provide more consistent threshold calculations.
*/
internal class ThresholdHandler(
// Factor to divide item size when calculating threshold.
// Depending on the speed, it dynamically varies in range 1..maxThresholdDivider
private val maxThresholdDivider: Float,
// Min velocity for threshold calculation
private val minVelocity: Float = 300f,
// Max velocity for threshold calculation
private val maxVelocity: Float = 3000f,
// Smoothing factor for velocity readings
private val smoothingConstant: Float = 0.4f,
private val averageItemSize: () -> Float
) {
private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f)
private val rotaryVelocityTracker = RotaryVelocityTracker()
private var smoothedVelocity = 0f
/**
* Resets tracking state in preparation for a new scroll event.
* Initiates the velocity tracker and resets smoothed velocity.
*/
fun startThresholdTracking(time: Long) {
rotaryVelocityTracker.start(time)
smoothedVelocity = 0f
}
/**
* Updates the velocity tracker with the latest rotary input data.
*/
fun updateTracking(timestamp: Long, delta: Float) {
rotaryVelocityTracker.move(timestamp, delta)
applySmoothing()
}
/**
* Calculates the dynamic snapping threshold based on the current smoothed velocity.
*
* @return The threshold, in pixels, required to trigger a snapping action.
*/
fun calculateSnapThreshold(): Float {
// Calculate a divider fraction based on the smoothedVelocity within the defined range.
val thresholdDividerFraction =
thresholdDividerEasing.transform(
inverseLerp(
minVelocity,
maxVelocity,
smoothedVelocity
)
)
// Calculate the final threshold size by dividing the average item size by a dynamically
// adjusted threshold divider.
return averageItemSize() / lerp(
1f,
maxThresholdDivider,
thresholdDividerFraction
)
}
/**
* Applies exponential smoothing to the tracked velocity to reduce noise
* and provide more consistent threshold calculations.
*/
private fun applySmoothing() {
if (rotaryVelocityTracker.velocity != 0.0f) {
// smooth the velocity
smoothedVelocity = exponentialSmoothing(
currentVelocity = rotaryVelocityTracker.velocity.absoluteValue,
prevVelocity = smoothedVelocity,
smoothingConstant = smoothingConstant
)
}
debugLog { "rotaryVelocityTracker velocity: ${rotaryVelocityTracker.velocity}" }
debugLog { "SmoothedVelocity: $smoothedVelocity" }
}
private fun exponentialSmoothing(
currentVelocity: Float,
prevVelocity: Float,
smoothingConstant: Float
): Float =
smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity
}
private data class RotaryHandlerElement(
private val rotaryBehavior: RotaryBehavior,
private val reverseDirection: Boolean,
private val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<RotaryInputNode>() {
override fun create(): RotaryInputNode = RotaryInputNode(
rotaryBehavior,
reverseDirection,
)
override fun update(node: RotaryInputNode) {
debugLog { "Update launched!" }
node.rotaryBehavior = rotaryBehavior
node.reverseDirection = reverseDirection
}
override fun InspectorInfo.inspectableProperties() {
inspectorInfo()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as RotaryHandlerElement
if (rotaryBehavior != other.rotaryBehavior) return false
if (reverseDirection != other.reverseDirection) return false
return true
}
override fun hashCode(): Int {
var result = rotaryBehavior.hashCode()
result = 31 * result + reverseDirection.hashCode()
return result
}
}
private class RotaryInputNode(
var rotaryBehavior: RotaryBehavior,
var reverseDirection: Boolean,
) : RotaryInputModifierNode, Modifier.Node() {
val channel = Channel<RotaryScrollEvent>(capacity = Channel.CONFLATED)
val flow = channel.receiveAsFlow()
override fun onAttach() {
coroutineScope.launch {
flow
.collectLatest { event ->
val (orientation: Orientation, deltaInPixels: Float) =
if (event.verticalScrollPixels != 0.0f)
Pair(Orientation.Vertical, event.verticalScrollPixels)
else
Pair(Orientation.Horizontal, event.horizontalScrollPixels)
debugLog {
"Scroll event received: " +
"delta:$deltaInPixels, timestamp:${event.uptimeMillis}"
}
with(rotaryBehavior) {
handleScrollEvent(
timestamp = event.uptimeMillis,
deltaInPixels = deltaInPixels * if (reverseDirection) -1f else 1f,
deviceId = event.inputDeviceId,
orientation = orientation,
)
}
}
}
}
override fun onRotaryScrollEvent(event: RotaryScrollEvent): Boolean = false
override fun onPreRotaryScrollEvent(event: RotaryScrollEvent): Boolean {
debugLog { "onPreRotaryScrollEvent" }
channel.trySend(event)
return true
}
}
/**
* Debug logging that can be enabled.
*/
private const val DEBUG = false
private inline fun debugLog(generateMsg: () -> String) {
if (DEBUG) {
println("RotaryScroll: ${generateMsg()}")
}
}