diff --git a/composables/src/main/java/com/google/android/horologist/composables/picker/PickerRotaryScrollAdapter.kt b/composables/src/main/java/com/google/android/horologist/composables/picker/PickerRotaryScrollAdapter.kt index 496062e4e1..886ab7f2e4 100644 --- a/composables/src/main/java/com/google/android/horologist/composables/picker/PickerRotaryScrollAdapter.kt +++ b/composables/src/main/java/com/google/android/horologist/composables/picker/PickerRotaryScrollAdapter.kt @@ -16,6 +16,8 @@ package com.google.android.horologist.composables.picker +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter @@ -24,8 +26,9 @@ import com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter /** * An extension function for creating [RotaryScrollAdapter] from [Picker] */ +@Composable internal fun PickerState.toRotaryScrollAdapter(): PickerRotaryScrollAdapter = - PickerRotaryScrollAdapter(this) + remember(this) { PickerRotaryScrollAdapter(this) } /** * An implementation of rotary scroll adapter for [Picker] diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index c041fb723d..83dd169c50 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -321,18 +321,6 @@ package com.google.android.horologist.compose.rotaryinput { method @com.google.android.horologist.annotations.ExperimentalHorologistApi public static androidx.compose.ui.Modifier onRotaryInputAccumulatedWithFocus(androidx.compose.ui.Modifier, optional androidx.compose.ui.focus.FocusRequester? focusRequester, optional boolean isLowRes, kotlin.jvm.functions.Function1 onValueChange); } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class AnimationScrollBehavior implements com.google.android.horologist.compose.rotaryinput.RotaryScrollBehavior { - ctor public AnimationScrollBehavior(androidx.compose.foundation.gestures.ScrollableState scrollableState); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? handleEvent(float targetValue, kotlin.coroutines.Continuation); - } - - @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class DefaultRotaryFlingBehavior implements com.google.android.horologist.compose.rotaryinput.RotaryFlingBehavior { - ctor public DefaultRotaryFlingBehavior(androidx.compose.foundation.gestures.ScrollableState scrollableState, androidx.compose.foundation.gestures.FlingBehavior flingBehavior, android.view.ViewConfiguration viewConfiguration, long flingTimeframe); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void observeEvent(long timestamp, float delta); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void startFlingTracking(long timestamp); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? trackFling(kotlin.jvm.functions.Function0 beforeFling, kotlin.coroutines.Continuation); - } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class DefaultRotaryHapticFeedback implements com.google.android.horologist.compose.rotaryinput.RotaryHapticFeedback { ctor public DefaultRotaryHapticFeedback(android.view.View view); method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void performHapticFeedback(int type); @@ -344,15 +332,6 @@ package com.google.android.horologist.compose.rotaryinput { method public void handleSnapHaptic(float scrollDelta); } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class DefaultSnapBehavior implements com.google.android.horologist.compose.rotaryinput.RotarySnapBehavior { - ctor public DefaultSnapBehavior(com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter rotaryScrollAdapter, com.google.android.horologist.compose.rotaryinput.SnapParameters snapParameters); - method public boolean bottomEdgeReached(); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void prepareSnapForItems(int moveForElements, boolean sequentialSnap); - method public suspend Object? snapToClosestItem(kotlin.coroutines.Continuation); - method public suspend Object? snapToTargetItem(kotlin.coroutines.Continuation); - method public boolean topEdgeReached(); - } - public final class GenericMotionRotaryInputAccumulator { ctor public GenericMotionRotaryInputAccumulator(android.content.Context context, kotlin.jvm.functions.Function1 onValueChange, optional long eventAccumulationThresholdMs, optional float minValueChangeDistancePx, optional long rateLimitCoolDownMs); method public boolean onGenericMotionEvent(android.view.MotionEvent event); @@ -365,19 +344,12 @@ package com.google.android.horologist.compose.rotaryinput { } @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class RotaryDefaults { + method public com.google.android.horologist.compose.rotaryinput.SnapParameters getSnapParametersDefault(); method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public boolean isLowResInput(); - method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public com.google.android.horologist.compose.rotaryinput.RotaryScrollHandler rememberFlingHandler(androidx.compose.foundation.gestures.ScrollableState scrollableState, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean isLowRes); - method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public com.google.android.horologist.compose.rotaryinput.RotaryScrollHandler rememberSnapHandler(com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter rotaryScrollAdapter, optional com.google.android.horologist.compose.rotaryinput.SnapParameters snapParameters, optional boolean isLowRes); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public com.google.android.horologist.compose.rotaryinput.SnapParameters snapParametersDefault(); + property public final com.google.android.horologist.compose.rotaryinput.SnapParameters snapParametersDefault; field public static final com.google.android.horologist.compose.rotaryinput.RotaryDefaults INSTANCE; } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotaryFlingBehavior { - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void observeEvent(long timestamp, float delta); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void startFlingTracking(long timestamp); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? trackFling(kotlin.jvm.functions.Function0 beforeFling, kotlin.coroutines.Continuation); - } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotaryHapticFeedback { method @com.google.android.horologist.annotations.ExperimentalHorologistApi public void performHapticFeedback(int type); } @@ -410,12 +382,10 @@ package com.google.android.horologist.compose.rotaryinput { } public final class RotaryKt { - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public static kotlinx.coroutines.flow.Flow batchRequestsWithinTimeframe(kotlinx.coroutines.flow.Flow, long timeframe); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public static androidx.compose.ui.Modifier rotaryHandler(androidx.compose.ui.Modifier, com.google.android.horologist.compose.rotaryinput.RotaryScrollHandler rotaryScrollHandler, boolean reverseDirection, com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics); method @Deprecated @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static androidx.compose.ui.Modifier rotaryWithFling(androidx.compose.ui.Modifier, androidx.compose.ui.focus.FocusRequester focusRequester, androidx.compose.foundation.gestures.ScrollableState scrollableState, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics, optional boolean reverseDirection); method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static androidx.compose.ui.Modifier rotaryWithScroll(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState scrollableState, optional androidx.compose.ui.focus.FocusRequester focusRequester, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics, optional boolean reverseDirection); method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static androidx.compose.ui.Modifier rotaryWithSnap(androidx.compose.ui.Modifier, com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter rotaryScrollAdapter, optional androidx.compose.ui.focus.FocusRequester focusRequester, optional com.google.android.horologist.compose.rotaryinput.SnapParameters snapParameters, optional com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics, optional boolean reverseDirection); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public static com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter toRotaryScrollAdapter(androidx.wear.compose.foundation.lazy.ScalingLazyListState); + method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter toRotaryScrollAdapter(androidx.wear.compose.foundation.lazy.ScalingLazyListState); } @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotaryScrollAdapter { @@ -427,22 +397,6 @@ package com.google.android.horologist.compose.rotaryinput { property public abstract androidx.compose.foundation.gestures.ScrollableState scrollableState; } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotaryScrollBehavior { - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? handleEvent(float targetValue, kotlin.coroutines.Continuation); - } - - @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotaryScrollHandler { - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? handleScrollEvent(kotlinx.coroutines.CoroutineScope coroutineScope, com.google.android.horologist.compose.rotaryinput.TimestampedDelta event, com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics, kotlin.coroutines.Continuation); - } - - @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotarySnapBehavior { - method public boolean bottomEdgeReached(); - method public void prepareSnapForItems(int moveForElements, boolean sequentialSnap); - method public suspend Object? snapToClosestItem(kotlin.coroutines.Continuation); - method public suspend Object? snapToTargetItem(kotlin.coroutines.Continuation); - method public boolean topEdgeReached(); - } - public final class RotaryVelocityTracker { ctor public RotaryVelocityTracker(); method public void end(); @@ -473,17 +427,6 @@ package com.google.android.horologist.compose.rotaryinput { property public final float thresholdDivider; } - @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class TimestampedDelta { - ctor public TimestampedDelta(long timestamp, float delta); - method public long component1(); - method public float component2(); - method public com.google.android.horologist.compose.rotaryinput.TimestampedDelta copy(long timestamp, float delta); - method public float getDelta(); - method public long getTimestamp(); - property public final float delta; - property public final long timestamp; - } - } package com.google.android.horologist.compose.snackbar { diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Rotary.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Rotary.kt index a77f370e7b..52ceaf7964 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Rotary.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Rotary.kt @@ -32,16 +32,18 @@ import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.rotary.onRotaryScrollEvent +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.util.fastSumBy import androidx.compose.ui.util.lerp @@ -60,21 +62,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.math.sign -private const val DEBUG = false - -/** - * Debug logging that can be enabled. - */ -private inline fun debugLog(generateMsg: () -> String) { - if (DEBUG) { - println("RotaryScroll: ${generateMsg()}") - } -} - /** * A modifier which connects rotary events with scrollable. * This modifier supports fling. @@ -127,6 +119,14 @@ public fun Modifier.rotaryWithFling( rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), reverseDirection = reverseDirection, rotaryHaptics = rotaryHaptics, + inspectorInfo = debugInspectorInfo { + name = "rotaryWithFling" + properties["focusRequester"] = focusRequester + properties["scrollableState"] = scrollableState + properties["flingBehavior"] = flingBehavior + properties["rotaryHaptics"] = rotaryHaptics + properties["reverseDirection"] = reverseDirection + }, ) .focusRequester(focusRequester) .focusable() @@ -158,6 +158,14 @@ public fun Modifier.rotaryWithScroll( rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), reverseDirection = reverseDirection, rotaryHaptics = rotaryHaptics, + inspectorInfo = debugInspectorInfo { + name = "rotaryWithFling" + properties["scrollableState"] = scrollableState + properties["focusRequester"] = focusRequester + properties["flingBehavior"] = flingBehavior + properties["rotaryHaptics"] = rotaryHaptics + properties["reverseDirection"] = reverseDirection + }, ) .focusRequester(focusRequester) .focusable() @@ -181,13 +189,21 @@ public fun Modifier.rotaryWithScroll( public fun Modifier.rotaryWithSnap( rotaryScrollAdapter: RotaryScrollAdapter, focusRequester: FocusRequester = rememberActiveFocusRequester(), - snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault(), + snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault, rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState), reverseDirection: Boolean = false, ): Modifier = rotaryHandler( rotaryScrollHandler = RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters), reverseDirection = reverseDirection, rotaryHaptics = rotaryHaptics, + inspectorInfo = debugInspectorInfo { + name = "rotaryWithFling" + properties["rotaryScrollAdapter"] = rotaryScrollAdapter + properties["focusRequester"] = focusRequester + properties["snapParameters"] = snapParameters + properties["rotaryHaptics"] = rotaryHaptics + properties["reverseDirection"] = reverseDirection + }, ) .focusRequester(focusRequester) .focusable() @@ -195,9 +211,10 @@ public fun Modifier.rotaryWithSnap( /** * An extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState] */ +@Composable @ExperimentalHorologistApi public fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter = - ScalingLazyColumnRotaryScrollAdapter(this) + remember(this) { ScalingLazyColumnRotaryScrollAdapter(this) } /** * An implementation of rotary scroll adapter for [ScalingLazyColumn] @@ -274,15 +291,33 @@ public interface RotaryScrollAdapter { @ExperimentalHorologistApi public object RotaryDefaults { + /** + * Returns default [SnapParameters] + */ + @ExperimentalHorologistApi + public val snapParametersDefault: SnapParameters = + SnapParameters( + snapOffset = 0, + thresholdDivider = 1.5f, + resistanceFactor = 3f, + ) + + /** + * Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb). + */ + @ExperimentalHorologistApi + @Composable + public fun isLowResInput(): Boolean = LocalContext.current.packageManager + .hasSystemFeature("android.hardware.rotaryencoder.lowres") + /** * Handles scroll with fling. * @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) */ - @ExperimentalHorologistApi @Composable - public fun rememberFlingHandler( + internal fun rememberFlingHandler( scrollableState: ScrollableState, flingBehavior: FlingBehavior? = null, isLowRes: Boolean = isLowResInput(), @@ -290,28 +325,33 @@ public object RotaryDefaults { val viewConfiguration = ViewConfiguration.get(LocalContext.current) return remember(scrollableState, flingBehavior, isLowRes) { - debugLog { "isLowRes : $isLowRes" } - fun rotaryFlingBehavior() = flingBehavior?.run { - DefaultRotaryFlingBehavior( - scrollableState, - flingBehavior, - viewConfiguration, - flingTimeframe = if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe, - ) - } - - fun scrollBehavior() = AnimationScrollBehavior(scrollableState) + // Remove unnecessary recompositions by disabling tracking of changes inside of + // this block. This algorithm properly reads all updated values and + // don't need recomposition when those values change. + Snapshot.withoutReadObservation { + debugLog { "isLowRes : $isLowRes" } + fun rotaryFlingBehavior() = flingBehavior?.run { + RotaryFlingBehavior( + scrollableState, + flingBehavior, + viewConfiguration, + flingTimeframe = if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe, + ) + } - if (isLowRes) { - LowResRotaryScrollHandler( - rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, - scrollBehaviorFactory = { scrollBehavior() }, - ) - } else { - HighResRotaryScrollHandler( - rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, - scrollBehaviorFactory = { scrollBehavior() }, - ) + fun scrollBehavior() = RotaryScrollBehavior(scrollableState) + + if (isLowRes) { + LowResRotaryScrollHandler( + rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, + scrollBehaviorFactory = { scrollBehavior() }, + ) + } else { + HighResRotaryScrollHandler( + rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, + scrollBehaviorFactory = { scrollBehavior() }, + ) + } } } } @@ -321,59 +361,45 @@ public object RotaryDefaults { * @param rotaryScrollAdapter A connection between scrollable objects and rotary events * @param snapParameters Snap parameters */ - @ExperimentalHorologistApi @Composable - public fun rememberSnapHandler( + internal fun rememberSnapHandler( rotaryScrollAdapter: RotaryScrollAdapter, - snapParameters: SnapParameters = snapParametersDefault(), + snapParameters: SnapParameters = snapParametersDefault, isLowRes: Boolean = isLowResInput(), ): RotaryScrollHandler { return remember(rotaryScrollAdapter, snapParameters) { - if (isLowRes) { - LowResSnapHandler( - snapBehaviourFactory = { - DefaultSnapBehavior(rotaryScrollAdapter, snapParameters) - }, - ) - } else { - HighResSnapHandler( - resistanceFactor = snapParameters.resistanceFactor, - thresholdBehaviorFactory = { - ThresholdBehavior( - rotaryScrollAdapter, - snapParameters.thresholdDivider, - ) - }, - snapBehaviourFactory = { - DefaultSnapBehavior(rotaryScrollAdapter, snapParameters) - }, - scrollBehaviourFactory = { - AnimationScrollBehavior(rotaryScrollAdapter.scrollableState) - }, - ) + // Remove unnecessary recompositions by disabling tracking of changes inside of + // this block. This algorithm properly reads all updated values and + // don't need recomposition when those values change. + Snapshot.withoutReadObservation { + debugLog { "isLowRes : $isLowRes" } + if (isLowRes) { + LowResSnapHandler( + snapBehaviourFactory = { + RotarySnapBehavior(rotaryScrollAdapter, snapParameters) + }, + ) + } else { + HighResSnapHandler( + resistanceFactor = snapParameters.resistanceFactor, + thresholdBehaviorFactory = { + ThresholdBehavior( + rotaryScrollAdapter, + snapParameters.thresholdDivider, + ) + }, + snapBehaviourFactory = { + RotarySnapBehavior(rotaryScrollAdapter, snapParameters) + }, + scrollBehaviourFactory = { + RotaryScrollBehavior(rotaryScrollAdapter.scrollableState) + }, + ) + } } } } - /** - * Returns default [SnapParameters] - */ - @ExperimentalHorologistApi - public fun snapParametersDefault(): SnapParameters = - SnapParameters( - snapOffset = 0, - thresholdDivider = 1.5f, - resistanceFactor = 3f, - ) - - /** - * Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb). - */ - @ExperimentalHorologistApi - @Composable - public fun isLowResInput(): Boolean = LocalContext.current.packageManager - .hasSystemFeature("android.hardware.rotaryencoder.lowres") - private val lowResFlingTimeframe: Long = 100L private val highResFlingTimeframe: Long = 30L } @@ -398,20 +424,38 @@ public class SnapParameters( snapOffset.toDp() } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as SnapParameters + + if (snapOffset != other.snapOffset) return false + if (thresholdDivider != other.thresholdDivider) return false + if (resistanceFactor != other.resistanceFactor) return false + + return true + } + + override fun hashCode(): Int { + var result = snapOffset + result = 31 * result + thresholdDivider.hashCode() + result = 31 * result + resistanceFactor.hashCode() + return result + } } /** * An interface for handling scroll events */ -@ExperimentalHorologistApi -public interface RotaryScrollHandler { +internal interface RotaryScrollHandler { /** * Handles scrolling events * @param coroutineScope A scope for performing async actions * @param event A scrollable event from rotary input, containing scrollable delta and timestamp * @param rotaryHaptics */ - @ExperimentalHorologistApi public suspend fun handleScrollEvent( coroutineScope: CoroutineScope, event: TimestampedDelta, @@ -420,27 +464,15 @@ public interface RotaryScrollHandler { } /** - * An interface for scrolling behavior + * Class responsible for Fling behaviour with rotary. + * It tracks and produces the fling when necessary */ -@ExperimentalHorologistApi -public interface RotaryScrollBehavior { - /** - * Handles scroll event to [targetValue] - */ - @ExperimentalHorologistApi - public suspend fun handleEvent(targetValue: Float) -} - -/** - * Default implementation of [RotaryFlingBehavior] - */ -@ExperimentalHorologistApi -public class DefaultRotaryFlingBehavior( +internal class RotaryFlingBehavior( private val scrollableState: ScrollableState, private val flingBehavior: FlingBehavior, viewConfiguration: ViewConfiguration, private val flingTimeframe: Long, -) : RotaryFlingBehavior { +) { // A time range during which the fling is valid. // For simplicity it's twice as long as [flingTimeframe] @@ -460,21 +492,28 @@ public class DefaultRotaryFlingBehavior( private var flingVelocity: Float = 0f private var flingTimestamp: Long = 0 - @ExperimentalHorologistApi - override fun startFlingTracking(timestamp: Long) { + /** + * Starts a new fling tracking session + * with specified timestamp + */ + fun startFlingTracking(timestamp: Long) { rotaryVelocityTracker.start(timestamp) latestEventTimestamp = timestamp previousVelocity = 0f } - @ExperimentalHorologistApi - override fun observeEvent(timestamp: Long, delta: Float) { + /** + * 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 } - @ExperimentalHorologistApi - override suspend fun trackFling(beforeFling: () -> Unit) { + /** + * Performing fling if necessary and calling [beforeFling] lambda before it is triggered + */ + suspend fun trackFling(beforeFling: () -> Unit) { val currentVelocity = rotaryVelocityTracker.velocity debugLog { "currentVelocity: $currentVelocity" } @@ -512,92 +551,25 @@ public class DefaultRotaryFlingBehavior( } } -/** - * An interface for flinging with rotary - */ -@ExperimentalHorologistApi -public interface RotaryFlingBehavior { - - /** - * Observing new event within a fling tracking session with new timestamp and delta - */ - @ExperimentalHorologistApi - public fun observeEvent(timestamp: Long, delta: Float) - - /** - * Performing fling if necessary and calling [beforeFling] lambda before it is triggered - */ - @ExperimentalHorologistApi - public suspend fun trackFling(beforeFling: () -> Unit) - - /** - * Starts a new fling tracking session - * with specified timestamp - */ - @ExperimentalHorologistApi - public fun startFlingTracking(timestamp: Long) -} - -/** - * An interface for snapping with rotary - */ -@ExperimentalHorologistApi -public interface RotarySnapBehavior { - - /** - * Preparing snapping. This method should be called before [snapToTargetItem] is called. - * - * Snapping is done for current + [moveForElements] items. - * - * If [sequentialSnap] is true, items are summed up together. - * For example, if [prepareSnapForItems] 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. - */ - public fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) - - /** - * Performs snapping to the closest item. - */ - public suspend fun snapToClosestItem() - - /** - * Returns true if top edge was reached - */ - public fun topEdgeReached(): Boolean - - /** - * Returns true if bottom edge was reached - */ - public fun bottomEdgeReached(): Boolean - - /** - * Performs snapping to the specified in [prepareSnapForItems] element - */ - public suspend fun snapToTargetItem() -} - /** * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta]. */ -@ExperimentalHorologistApi -public data class TimestampedDelta(val timestamp: Long, val delta: Float) +internal data class TimestampedDelta(val timestamp: Long, val delta: Float) -/** Animation implementation of [RotaryScrollBehavior]. - * This class does a smooth animation when the scroll by N pixels is done. +/**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. */ -@ExperimentalHorologistApi -public class AnimationScrollBehavior( +internal class RotaryScrollBehavior( private val scrollableState: ScrollableState, -) : RotaryScrollBehavior { +) { private var sequentialAnimation = false private var scrollAnimation = AnimationState(0f) private var prevPosition = 0f - @ExperimentalHorologistApi - override suspend fun handleEvent(targetValue: Float) { + /** + * Handles scroll event to [targetValue] + */ + suspend fun handleEvent(targetValue: Float) { scrollableState.scroll(MutatePriority.UserInput) { debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" } @@ -617,14 +589,13 @@ public class AnimationScrollBehavior( } /** - * An animated implementation of [RotarySnapBehavior]. Uses animateScrollToItem - * method for snapping to the Nth item + * A helper class for snapping with rotary. Uses animateScrollToItem + * method for snapping to the Nth item. */ -@ExperimentalHorologistApi -public class DefaultSnapBehavior( +internal class RotarySnapBehavior( private val rotaryScrollAdapter: RotaryScrollAdapter, private val snapParameters: SnapParameters, -) : RotarySnapBehavior { +) { private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex() private var sequentialSnap: Boolean = false @@ -634,8 +605,18 @@ public class DefaultSnapBehavior( private val defaultStiffness = 200f private var snapTargetUpdated = true - @ExperimentalHorologistApi - override fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) { + /** + * Preparing snapping. This method should be called before [snapToTargetItem] is called. + * + * Snapping is done for current + [moveForElements] items. + * + * If [sequentialSnap] is true, items are summed up together. + * For example, if [prepareSnapForItems] 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 prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) { this.sequentialSnap = sequentialSnap if (sequentialSnap) { snapTarget += moveForElements @@ -646,7 +627,10 @@ public class DefaultSnapBehavior( snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount()) } - override suspend fun snapToClosestItem() { + /** + * Performs snapping to the closest item. + */ + suspend fun snapToClosestItem() { // Snapping to the closest item by using performFling method with 0 speed rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) { debugLog { "snap to closest item" } @@ -663,12 +647,21 @@ public class DefaultSnapBehavior( } } - override fun topEdgeReached(): Boolean = snapTarget <= 0 + /** + * Returns true if top edge was reached + */ + fun topEdgeReached(): Boolean = snapTarget <= 0 - override fun bottomEdgeReached(): Boolean = + /** + * Returns true if bottom edge was reached + */ + fun bottomEdgeReached(): Boolean = snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1 - override suspend fun snapToTargetItem() { + /** + * Performs snapping to the specified in [prepareSnapForItems] element + */ + suspend fun snapToTargetItem() { if (sequentialSnap) { anim = anim.copy(0f) } else { @@ -773,57 +766,25 @@ public class DefaultSnapBehavior( * It accepts ScrollHandler as the input - a class where main logic about how * scroll should be handled is lying */ -@ExperimentalHorologistApi -@OptIn(ExperimentalComposeUiApi::class) -public fun Modifier.rotaryHandler( +internal fun Modifier.rotaryHandler( rotaryScrollHandler: RotaryScrollHandler, - // TODO: batching causes additional delays. Return once it's clear that - // we will use it - /* batchTimeframe: Long = 0L,*/ reverseDirection: Boolean, rotaryHaptics: RotaryHapticHandler, -): Modifier = composed { - val channel = rememberTimestampChannel() - val eventsFlow = remember(channel) { channel.receiveAsFlow() } - - composed { - LaunchedEffect(eventsFlow) { - eventsFlow - // TODO: batching causes additional delays. Return once it's clear that - // we will use it - // Do we really need to do this on this level? -// .batchRequestsWithinTimeframe(batchTimeframe) - .collectLatest { - debugLog { - "Scroll event received: " + - "delta:${it.delta}, timestamp:${it.timestamp}" - } - rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics) - } - } - this - .onRotaryScrollEvent { - // Okay to ignore the ChannelResult returned from trySend because it is conflated - // (see rememberTimestampChannel()). - @Suppress("UNUSED_VARIABLE") - val unused = channel.trySend( - TimestampedDelta( - it.uptimeMillis, - it.verticalScrollPixels * if (reverseDirection) -1f else 1f, - ), - ) - true - } - } -} + inspectorInfo: InspectorInfo.() -> Unit, + +): Modifier = this then RotaryHandlerElement( + rotaryScrollHandler, + reverseDirection, + rotaryHaptics, + inspectorInfo, +) /** * Batching requests for scrolling events. This function combines all events together * (except first) within specified timeframe. Should help with performance on high-res devices. */ -@ExperimentalHorologistApi @OptIn(ExperimentalCoroutinesApi::class) -public fun Flow.batchRequestsWithinTimeframe(timeframe: Long): Flow { +internal fun Flow.batchRequestsWithinTimeframe(timeframe: Long): Flow { var delta = 0f var lastTimestamp = -timeframe return if (timeframe == 0L) { @@ -923,7 +884,7 @@ internal class HighResRotaryScrollHandler( } private fun isOppositeValueAfterScroll(delta: Float): Boolean = - sign(rotaryScrollDistance) * sign(delta) == -1f && + rotaryScrollDistance * delta < 0f && (abs(delta) < abs(rotaryScrollDistance)) private fun isNewScrollEvent(timestamp: Long): Boolean { @@ -1284,11 +1245,98 @@ internal class ThresholdBehavior( smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity } -@Composable -private fun rememberTimestampChannel() = remember { - Channel(capacity = Channel.CONFLATED) +private data class RotaryHandlerElement( + private val rotaryScrollHandler: RotaryScrollHandler, + private val reverseDirection: Boolean, + private val rotaryHaptics: RotaryHapticHandler, + private val inspectorInfo: InspectorInfo.() -> Unit, +) : ModifierNodeElement() { + override fun create(): RotaryInputNode = RotaryInputNode( + rotaryScrollHandler, + reverseDirection, + rotaryHaptics, + ) + + override fun update(node: RotaryInputNode) { + debugLog { "Update launched!" } + node.rotaryScrollHandler = rotaryScrollHandler + node.reverseDirection = reverseDirection + node.rotaryHaptics = rotaryHaptics + } + + 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 (rotaryScrollHandler != other.rotaryScrollHandler) return false + if (reverseDirection != other.reverseDirection) return false + if (rotaryHaptics != other.rotaryHaptics) return false + if (inspectorInfo != other.inspectorInfo) return false + + return true + } + + override fun hashCode(): Int { + var result = rotaryScrollHandler.hashCode() + result = 31 * result + reverseDirection.hashCode() + result = 31 * result + rotaryHaptics.hashCode() + result = 31 * result + inspectorInfo.hashCode() + return result + } +} + +private class RotaryInputNode( + var rotaryScrollHandler: RotaryScrollHandler, + var reverseDirection: Boolean, + var rotaryHaptics: RotaryHapticHandler, +) : RotaryInputModifierNode, Modifier.Node() { + + val channel = Channel(capacity = Channel.CONFLATED) + val flow = channel.receiveAsFlow() + + override fun onAttach() { + coroutineScope.launch { + flow + .collectLatest { + debugLog { + "Scroll event received: " + "delta:${it.delta}, timestamp:${it.timestamp}" + } + rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics) + } + } + } + + override fun onRotaryScrollEvent(event: RotaryScrollEvent): Boolean = false + + override fun onPreRotaryScrollEvent(event: RotaryScrollEvent): Boolean { + debugLog { "onPreRotaryScrollEvent" } + channel.trySend( + TimestampedDelta( + event.uptimeMillis, + event.verticalScrollPixels * if (reverseDirection) -1f else 1f, + ), + ) + return true + } } private fun inverseLerp(start: Float, stop: Float, value: Float): Float { return ((value - start) / (stop - start)).coerceIn(0f, 1f) } + +/** + * Debug logging that can be enabled. + */ +private const val DEBUG = false + +private inline fun debugLog(generateMsg: () -> String) { + if (DEBUG) { + println("RotaryScroll: ${generateMsg()}") + } +}