[go: nahoru, domu]

blob: 72e6f569fbf066667385417c32cc73b320c51e80 [file] [log] [blame]
/*
* 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.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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 androidx.wear.compose.foundation.rememberActiveFocusRequester
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
* [RotaryScrollableBehavior] directly as a parameter.
*
* This modifier handles rotary input devices, used for scrolling. These devices can be categorized
* as high-resolution or low-resolution based on their precision.
*
* - High-res devices: Offer finer control and can detect smaller rotations.
* This allows for more precise adjustments during scrolling. One example of a high-res
* device is the crown (also known as rotating side button), located on the side of the watch.
* - Low-res devices: Have less granular control, registering larger rotations
* at a time. Scrolling behavior is adapted to compensate for these larger jumps. Examples
* include physical or virtual bezels, positioned around the screen.
*
* This modifier supports rotary scrolling and snapping.
* The behaviour is configured by the provided [RotaryScrollableBehavior]:
* either provide [RotaryScrollableDefaults.behavior] for scrolling with/without fling
* or pass [RotaryScrollableDefaults.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 behavior Specified [RotaryScrollableBehavior] 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.
* We recommend using [rememberActiveFocusRequester] to obtain a FocusRequester, as this
* will guarantee the proper behavior.
* @param reverseDirection Reverse the direction of scrolling if required for consistency
* with the scrollable state passed via [behavior].
*/
fun Modifier.rotaryScrollable(
behavior: RotaryScrollableBehavior,
focusRequester: FocusRequester,
reverseDirection: Boolean = false
): Modifier =
rotaryHandler(
behavior = behavior,
reverseDirection = reverseDirection,
)
.focusRequester(focusRequester)
.focusable()
/**
* An interface for handling scroll events. Has implementations for handling scroll
* with/without fling [FlingRotaryScrollableBehavior] and for handling snap
* [LowResSnapRotaryScrollableBehavior], [HighResSnapRotaryScrollableBehavior] (see
* [Modifier.rotaryScrollable] for descriptions of low-res and high-res devices).
*/
interface RotaryScrollableBehavior {
/**
* Executes a scrolling operation based on rotary input.
*
* @param timestampMillis The time in milliseconds at which this even occurred
* @param delta The amount to scroll, in pixels
* @param inputDeviceId The id for the input device that this event came from
* @param orientation Orientation of the scrolling
*/
suspend fun CoroutineScope.performScroll(
timestampMillis: Long,
delta: Float,
inputDeviceId: Int,
orientation: Orientation
)
}
/**
* A provider 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 RotarySnapLayoutInfoProvider {
/**
* The average size in pixels of an item within the scrollable. This is used to
* estimate scrolling distances for snapping when responding to rotary input.
*/
val averageItemSize: Float
/**
* The index of the item that is closest to the center.
*/
val currentItemIndex: Int
/**
* The offset in pixels of the currently centered item from its centered position.
* This value can be positive or negative.
*/
val currentItemOffset: Float
/**
* The total number of items within the scrollable
*/
val totalItemCount: Int
}
/**
* Defaults for rotaryScrollable modifier
*/
object RotaryScrollableDefaults {
/**
* Implementation of [RotaryScrollableBehavior] to define scrolling behaviour with or without
* fling - used with the [rotaryScrollable] 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 behavior(
scrollableState: ScrollableState,
flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(),
hapticFeedbackEnabled: Boolean = true
): RotaryScrollableBehavior {
val isLowRes = isLowResInput()
val viewConfiguration = ViewConfiguration.get(LocalContext.current)
val rotaryHaptics: RotaryHapticHandler =
rememberRotaryHapticHandler(scrollableState, hapticFeedbackEnabled)
return flingBehavior(
scrollableState,
rotaryHaptics,
flingBehavior,
isLowRes,
viewConfiguration
)
}
/**
* Implementation of [RotaryScrollableBehavior] to define scrolling behaviour with snap -
* used with the [rotaryScrollable] modifier when snapping is required.
*
* @param scrollableState Scrollable state which will be scrolled
* while receiving rotary events.
* @param layoutInfoProvider A connection between scrollable entities
* and rotary events.
* @param snapOffset An optional offset to be applied when snapping the item. Defines the
* distance from the center of the scrollable to the center of the snapped 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(
scrollableState: ScrollableState,
layoutInfoProvider: RotarySnapLayoutInfoProvider,
snapOffset: Dp = 0.dp,
hapticFeedbackEnabled: Boolean = true
): RotaryScrollableBehavior {
val isLowRes = isLowResInput()
val snapOffsetPx = with(LocalDensity.current) { snapOffset.roundToPx() }
val rotaryHaptics: RotaryHapticHandler =
rememberRotaryHapticHandler(
scrollableState,
hapticFeedbackEnabled
)
return remember(
scrollableState, layoutInfoProvider,
rotaryHaptics, snapOffset, isLowRes
) {
snapBehavior(
scrollableState,
layoutInfoProvider,
rotaryHaptics,
snapOffsetPx,
ThresholdDivider,
ResistanceFactor,
isLowRes
)
}
}
/**
* Implementation of [RotaryScrollableBehavior] to define scrolling behaviour with snap for
* [ScalingLazyColumn] - used with the [rotaryScrollable] modifier when snapping is required.
*
* @param scrollableState [ScalingLazyListState] to which rotary scroll will be connected.
* @param snapOffset An optional offset to be applied when snapping the item. Defines the
* distance from the center of the scrollable to the center of the snapped 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(
scrollableState: ScalingLazyListState,
snapOffset: Dp = 0.dp,
hapticFeedbackEnabled: Boolean = true
): RotaryScrollableBehavior = snapBehavior(
scrollableState = scrollableState,
layoutInfoProvider = remember(scrollableState) {
ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
},
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 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 ScalingLazyColumnRotarySnapLayoutInfoProvider(
private val scrollableState: ScalingLazyListState
) : RotarySnapLayoutInfoProvider {
/**
* Calculates the average item height by averaging the height of visible items.
*/
override val averageItemSize: Float
get() {
val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
}
/**
* Current (centered) item index
*/
override val currentItemIndex: Int
get() = scrollableState.centerItemIndex
/**
* The offset from the item center.
*/
override val currentItemOffset: Float
get() = scrollableState.centerItemScrollOffset.toFloat()
/**
* The total count of items in ScalingLazyColumn
*/
override val totalItemCount: Int
get() = scrollableState.layoutInfo.totalItemsCount
}
/**
* Handles scroll with fling.
*
* @return A scroll with fling implementation of [RotaryScrollableBehavior] which is suitable
* for both low-res and high-res inputs (see [Modifier.rotaryScrollable] for descriptions
* of low-res and high-res devices).
*
* @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
): RotaryScrollableBehavior {
fun rotaryFlingHandler() = flingBehavior?.run {
RotaryFlingHandler(
scrollableState,
flingBehavior,
viewConfiguration,
flingTimeframe = if (isLowRes) RotaryScrollableDefaults.LowResFlingTimeframe
else RotaryScrollableDefaults.HighResFlingTimeframe
)
}
fun scrollHandler() = RotaryScrollHandler(scrollableState)
return FlingRotaryScrollableBehavior(
isLowRes,
rotaryHaptics,
rotaryFlingHandlerFactory = { rotaryFlingHandler() },
scrollHandlerFactory = { scrollHandler() }
)
}
/**
* Handles scroll with snap.
*
* @return A snap implementation of [RotaryScrollableBehavior] which is either suitable for low-res
* or high-res input (see [Modifier.rotaryScrollable] for descriptions of low-res
* and high-res devices).
*
* @param layoutInfoProvider Implementation of [RotarySnapLayoutInfoProvider], 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]. In pixels.
* @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(
scrollableState: ScrollableState,
layoutInfoProvider: RotarySnapLayoutInfoProvider,
rotaryHaptics: RotaryHapticHandler,
snapOffset: Int,
maxThresholdDivider: Float,
scrollDistanceDivider: Float,
isLowRes: Boolean
): RotaryScrollableBehavior {
return if (isLowRes) {
LowResSnapRotaryScrollableBehavior(
rotaryHaptics = rotaryHaptics,
snapHandlerFactory = {
RotarySnapHandler(
scrollableState,
layoutInfoProvider,
snapOffset,
)
}
)
} else {
HighResSnapRotaryScrollableBehavior(
rotaryHaptics = rotaryHaptics,
scrollDistanceDivider = scrollDistanceDivider,
thresholdHandlerFactory = {
ThresholdHandler(
maxThresholdDivider,
averageItemSize = { layoutInfoProvider.averageItemSize }
)
},
snapHandlerFactory = {
RotarySnapHandler(
scrollableState,
layoutInfoProvider,
snapOffset,
)
},
scrollHandlerFactory = {
RotaryScrollHandler(scrollableState)
}
)
}
}
/**
* An abstract base class for handling scroll events. Has implementations for handling scroll
* with/without fling [FlingRotaryScrollableBehavior] and for handling snap
* [LowResSnapRotaryScrollableBehavior], [HighResSnapRotaryScrollableBehavior] (see
* [Modifier.rotaryScrollable] for descriptions of low-res and high-res devices ).
*/
internal abstract class BaseRotaryScrollableBehavior : RotaryScrollableBehavior {
// 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 scrollableState: ScrollableState,
private val layoutInfoProvider: RotarySnapLayoutInfoProvider,
private val snapOffset: Int,
) {
private var snapTarget: Int = layoutInfoProvider.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 = layoutInfoProvider.currentItemIndex + moveForElements
}
snapTargetUpdated = true
snapTarget = snapTarget
.coerceIn(0 until layoutInfoProvider.totalItemCount)
}
/**
* Performs snapping to the closest item.
*/
suspend fun snapToClosestItem() {
// Perform the snapping animation
scrollableState.scroll(MutatePriority.UserInput) {
debugLog { "snap to the closest item" }
var prevPosition = 0f
// Create and execute the snap animation
AnimationState(0f).animateTo(
targetValue = -layoutInfoProvider.currentItemOffset,
animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing)
) {
val animDelta = value - prevPosition
scrollBy(animDelta)
prevPosition = value
}
// Update the snap target to ensure consistency
snapTarget = layoutInfoProvider.currentItemIndex
}
}
/**
* Returns true if top edge was reached
*/
fun topEdgeReached(): Boolean = snapTarget <= 0
/**
* Returns true if bottom edge was reached
*/
fun bottomEdgeReached(): Boolean =
snapTarget >= layoutInfoProvider.totalItemCount - 1
/**
* Performs snapping to the specified in [updateSnapTarget] element
*/
suspend fun snapToTargetItem() {
if (!sequentialSnap) anim = AnimationState(0f)
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 = layoutInfoProvider.currentItemIndex
expectedDistance = expectedDistanceTo(snapTarget, snapOffset)
debugLog {
"expectedDistance = $expectedDistance, " +
"scrollableState.centerItemScrollOffset " +
"${layoutInfoProvider.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 != layoutInfoProvider.currentItemIndex) {
continueFirstScroll = true
cancelAnimation()
return@animateTo
}
debugLog {
"centerItemIndex = ${layoutInfoProvider.currentItemIndex}"
}
if (layoutInfoProvider.currentItemIndex == snapTarget) {
debugLog { "Target is near the centre. Cancelling first animation" }
debugLog {
"scrollableState.centerItemScrollOffset " +
"${layoutInfoProvider.currentItemOffset}"
}
expectedDistance =
-layoutInfoProvider.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 = layoutInfoProvider.averageItemSize
val indexesDiff = index - layoutInfoProvider.currentItemIndex
debugLog { "Average size $averageSize" }
return (averageSize * indexesDiff) +
targetScrollOffset - layoutInfoProvider.currentItemOffset
}
}
/**
* A modifier which handles rotary events.
* It accepts [RotaryScrollableBehavior] as the input - a class that handles the main scroll logic.
*/
internal fun Modifier.rotaryHandler(
behavior: RotaryScrollableBehavior,
reverseDirection: Boolean,
inspectorInfo: InspectorInfo.() -> Unit = debugInspectorInfo {
name = "rotaryHandler"
properties["behavior"] = behavior
properties["reverseDirection"] = reverseDirection
}
): Modifier = this then RotaryHandlerElement(
behavior,
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 ) - see [Modifier.rotaryScrollable] for descriptions
* of low-res and high-res devices.
*
* This scroll behavior supports fling. It can be set with [RotaryFlingHandler].
*/
internal class FlingRotaryScrollableBehavior(
private val isLowRes: Boolean,
private val rotaryHaptics: RotaryHapticHandler,
private val rotaryFlingHandlerFactory: () -> RotaryFlingHandler?,
private val scrollHandlerFactory: () -> RotaryScrollHandler,
) : BaseRotaryScrollableBehavior() {
private var rotaryScrollDistance = 0f
private var rotaryFlingHandler: RotaryFlingHandler? = rotaryFlingHandlerFactory()
private var scrollHandler: RotaryScrollHandler = scrollHandlerFactory()
override suspend fun CoroutineScope.performScroll(
timestampMillis: Long,
delta: Float,
inputDeviceId: Int,
orientation: Orientation
) {
debugLog { "FlingRotaryScrollableBehavior: performScroll" }
if (isNewScrollEvent(timestampMillis)) {
debugLog { "New scroll event" }
resetScrolling()
resetFlingTracking(timestampMillis)
} 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(delta)) {
rotaryFlingHandler?.observeEvent(timestampMillis, delta)
} else {
debugLog { "Opposite value after scroll :$delta" }
}
}
rotaryScrollDistance += delta
debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
rotaryHaptics.handleScrollHaptic(timestampMillis, delta)
previousScrollEventTime = timestampMillis
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 (see
* [Modifier.rotaryScrollable] for descriptions of low-res and high-res devices ).
*
* 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 HighResSnapRotaryScrollableBehavior(
private val rotaryHaptics: RotaryHapticHandler,
private val scrollDistanceDivider: Float,
private val thresholdHandlerFactory: () -> ThresholdHandler,
private val snapHandlerFactory: () -> RotarySnapHandler,
private val scrollHandlerFactory: () -> RotaryScrollHandler
) : BaseRotaryScrollableBehavior() {
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.performScroll(
timestampMillis: Long,
delta: Float,
inputDeviceId: Int,
orientation: Orientation
) {
debugLog { "HighResSnapRotaryScrollableBehavior: performScroll" }
if (isNewScrollEvent(timestampMillis)) {
debugLog { "New scroll event" }
resetScrolling()
resetSnapping()
resetThresholdTracking(timestampMillis)
}
if (!isOppositeValueAfterScroll(delta)) {
thresholdHandler.updateTracking(timestampMillis, delta)
} else {
debugLog { "Opposite value after scroll :$delta" }
}
val snapThreshold = thresholdHandler.calculateSnapThreshold()
debugLog { "snapThreshold: $snapThreshold" }
if (!snapJob.isActive) {
val proximityFactor = calculateProximityFactor(snapThreshold)
rotaryScrollDistance += delta * proximityFactor
}
debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
accumulatedSnapDelta += delta
debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }
previousScrollEventTime = timestampMillis
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(timestampMillis, delta)
}
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 (see
* [Modifier.rotaryScrollable] for descriptions of low-res and high-res devices ).
*
* This scroll behavior doesn't support fling.
*/
internal class LowResSnapRotaryScrollableBehavior(
private val rotaryHaptics: RotaryHapticHandler,
private val snapHandlerFactory: () -> RotarySnapHandler
) : BaseRotaryScrollableBehavior() {
private var snapJob: Job = CompletableDeferred<Unit>()
private var accumulatedSnapDelta = 0f
private var snapHandler = snapHandlerFactory()
override suspend fun CoroutineScope.performScroll(
timestampMillis: Long,
delta: Float,
inputDeviceId: Int,
orientation: Orientation
) {
debugLog { "LowResSnapRotaryScrollableBehavior: performScroll" }
if (isNewScrollEvent(timestampMillis)) {
debugLog { "New scroll event" }
resetSnapping()
}
accumulatedSnapDelta += delta
debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }
previousScrollEventTime = timestampMillis
if (abs(accumulatedSnapDelta) > 1f) {
val snapDistanceInItems = sign(accumulatedSnapDelta).toInt()
rotaryHaptics.handleSnapHaptic(timestampMillis, delta)
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 behavior: RotaryScrollableBehavior,
private val reverseDirection: Boolean,
private val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<RotaryInputNode>() {
override fun create(): RotaryInputNode = RotaryInputNode(
behavior,
reverseDirection,
)
override fun update(node: RotaryInputNode) {
debugLog { "Update launched!" }
node.behavior = behavior
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 (behavior != other.behavior) return false
if (reverseDirection != other.reverseDirection) return false
return true
}
override fun hashCode(): Int {
var result = behavior.hashCode()
result = 31 * result + reverseDirection.hashCode()
return result
}
}
private class RotaryInputNode(
var behavior: RotaryScrollableBehavior,
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(behavior) {
performScroll(
timestampMillis = event.uptimeMillis,
delta = deltaInPixels * if (reverseDirection) -1f else 1f,
inputDeviceId = 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()}")
}
}