diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index a2459b4d8b..39a8b8302c 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -311,9 +311,11 @@ package com.google.android.horologist.compose.rotaryinput { @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 @com.google.android.horologist.annotations.ExperimentalHorologistApi public float snapThreshold(boolean duringSnap); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? startSnappingSession(boolean toClosestItem, kotlin.coroutines.Continuation); + method public suspend Object? snapToClosestItem(kotlin.coroutines.Continuation); + method public suspend Object? snapToTargetItem(kotlin.coroutines.Continuation); + method public boolean topEdgeReached(); } public final class GenericMotionRotaryInputAccumulator { @@ -330,7 +332,7 @@ package com.google.android.horologist.compose.rotaryinput { @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class RotaryDefaults { 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); + 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(); field public static final com.google.android.horologist.compose.rotaryinput.RotaryDefaults INSTANCE; } @@ -374,10 +376,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, optional long batchTimeframe, boolean reverseDirection, com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics); - method @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.ui.focus.FocusRequester focusRequester, androidx.compose.foundation.gestures.ScrollableState scrollableState, 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, androidx.compose.ui.focus.FocusRequester focusRequester, com.google.android.horologist.compose.rotaryinput.RotaryScrollAdapter rotaryScrollAdapter, optional com.google.android.horologist.compose.rotaryinput.RotaryHapticHandler rotaryHaptics, optional boolean reverseDirection); + 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); } @@ -386,6 +388,7 @@ package com.google.android.horologist.compose.rotaryinput { method @com.google.android.horologist.annotations.ExperimentalHorologistApi public int currentItemIndex(); method @com.google.android.horologist.annotations.ExperimentalHorologistApi public float currentItemOffset(); method public androidx.compose.foundation.gestures.ScrollableState getScrollableState(); + method @com.google.android.horologist.annotations.ExperimentalHorologistApi public int totalItemsCount(); property public abstract androidx.compose.foundation.gestures.ScrollableState scrollableState; } @@ -398,9 +401,11 @@ package com.google.android.horologist.compose.rotaryinput { } @com.google.android.horologist.annotations.ExperimentalHorologistApi public interface RotarySnapBehavior { + method public boolean bottomEdgeReached(); method public void prepareSnapForItems(int moveForElements, boolean sequentialSnap); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public float snapThreshold(boolean duringSnap); - method @com.google.android.horologist.annotations.ExperimentalHorologistApi public suspend Object? startSnappingSession(boolean toClosestItem, kotlin.coroutines.Continuation); + method public suspend Object? snapToClosestItem(kotlin.coroutines.Continuation); + method public suspend Object? snapToTargetItem(kotlin.coroutines.Continuation); + method public boolean topEdgeReached(); } public final class RotaryVelocityTracker { @@ -418,14 +423,19 @@ package com.google.android.horologist.compose.rotaryinput { method public int currentItemIndex(); method public float currentItemOffset(); method public androidx.wear.compose.foundation.lazy.ScalingLazyListState getScrollableState(); + method public int totalItemsCount(); property public androidx.wear.compose.foundation.lazy.ScalingLazyListState scrollableState; } public final class SnapParameters { - ctor public SnapParameters(int snapOffset); + ctor public SnapParameters(int snapOffset, float thresholdDivider, float resistanceFactor); + method public float getResistanceFactor(); method public int getSnapOffset(); + method public float getThresholdDivider(); method @androidx.compose.runtime.Composable public float snapOffsetDp(); + property public final float resistanceFactor; property public final int snapOffset; + property public final float thresholdDivider; } @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class TimestampedDelta { diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt index 6310b37bfa..76e6260b13 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt @@ -40,7 +40,6 @@ import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode import com.google.android.horologist.compose.rotaryinput.rememberDisabledHaptic import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import com.google.android.horologist.compose.rotaryinput.rotaryWithSnap import com.google.android.horologist.compose.rotaryinput.toRotaryScrollAdapter @@ -137,14 +136,7 @@ public fun ScalingLazyColumn( rotaryHaptics = rotaryHaptics ) - RotaryMode.Fling -> modifier.rotaryWithFling( - focusRequester = focusRequester, - scrollableState = columnState.state, - reverseDirection = columnState.reverseLayout, - rotaryHaptics = rotaryHaptics - ) - - RotaryMode.Scroll -> modifier.rotaryWithScroll( + else -> modifier.rotaryWithScroll( focusRequester = focusRequester, scrollableState = columnState.state, reverseDirection = columnState.reverseLayout, diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Haptics.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Haptics.kt index c32707a187..a9d412e593 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Haptics.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/Haptics.kt @@ -219,7 +219,7 @@ public fun rememberDisabledHaptic(): RotaryHapticHandler = remember { @Composable public fun rememberRotaryHapticHandler( scrollableState: ScrollableState, - throttleThresholdMs: Long = 40, + throttleThresholdMs: Long = 30, hapticsThresholdPx: Long = 50, hapticsChannel: Channel = rememberHapticChannel(), rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback() @@ -309,8 +309,11 @@ private class PixelWatchRotaryHapticFeedback(private val view: View) : RotaryHap when (type) { RotaryHapticsType.ScrollItemFocus -> { view.performHapticFeedback( - if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_ITEM_FOCUS - else WEAR_SCROLL_ITEM_FOCUS + if (Build.VERSION.SDK_INT >= 33) { + ROTARY_SCROLL_ITEM_FOCUS + } else { + WEAR_SCROLL_ITEM_FOCUS + } ) } @@ -343,7 +346,7 @@ private class PixelWatchRotaryHapticFeedback(private val view: View) : RotaryHap } /** - * Implementation of [RotaryHapticFeedback] for Galaxy Watch 4 Classic + * Implementation of [RotaryHapticFeedback] for Galaxy Watch 4 and 6 Classic */ @ExperimentalHorologistApi private class GalaxyWatchClassicHapticFeedback(private val view: View) : RotaryHapticFeedback { @@ -369,7 +372,7 @@ private class GalaxyWatchClassicHapticFeedback(private val view: View) : RotaryH } private fun isGalaxyWatchClassic(): Boolean = - Build.MODEL.matches("SM-R8[89]5.".toRegex()) + Build.MODEL.matches("SM-R(?:8[89][05]|9[56][05])".toRegex()) private fun isGooglePixelWatch(): Boolean = Build.MODEL.startsWith("Google Pixel Watch") 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 ad3d37b1e1..f5700a5e46 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 @@ -18,6 +18,8 @@ package com.google.android.horologist.compose.rotaryinput 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 @@ -42,7 +44,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastSumBy +import androidx.compose.ui.util.lerp +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.lazy.ScalingLazyListState +import androidx.wear.compose.foundation.rememberActiveFocusRequester import com.google.android.horologist.annotations.ExperimentalHorologistApi import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -56,6 +61,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.transformLatest import kotlin.math.abs +import kotlin.math.absoluteValue import kotlin.math.sign private const val DEBUG = false @@ -103,6 +109,13 @@ private inline fun debugLog(generateMsg: () -> String) { */ @ExperimentalHorologistApi @Suppress("ComposableModifierFactory") +@Deprecated( + "Use rotaryWithScroll instead", + ReplaceWith( + "this.rotaryWithScroll(scrollableState, focusRequester, " + + "flingBehavior, rotaryHaptics, reverseDirection)" + ) +) @Composable public fun Modifier.rotaryWithFling( focusRequester: FocusRequester, @@ -120,31 +133,29 @@ public fun Modifier.rotaryWithFling( /** * A modifier which connects rotary events with scrollable. - * This modifier only supports scroll without fling or snap. - * The screen containing the scrollable item should request the focus - * by calling [requestFocus] method + * This modifier supports scroll with fling. * - * ``` - * LaunchedEffect(Unit) { - * focusRequester.requestFocus() - * } - * ``` - * @param focusRequester Requests the focus for rotary input * @param scrollableState Scrollable state which will be scrolled while receiving rotary events + * @param focusRequester Requests the focus for rotary input. + * By default comes from [rememberActiveFocusRequester], + * which is used with [HierarchicalFocusCoordinator] + * @param flingBehavior Logic describing fling behavior. If null fling will not happen. * @param rotaryHaptics Class which will handle haptic feedback * @param reverseDirection Reverse the direction of scrolling. Should be aligned with * Scrollable `reverseDirection` parameter */ +@OptIn(ExperimentalWearFoundationApi::class) @ExperimentalHorologistApi @Suppress("ComposableModifierFactory") @Composable public fun Modifier.rotaryWithScroll( - focusRequester: FocusRequester, scrollableState: ScrollableState, + focusRequester: FocusRequester = rememberActiveFocusRequester(), + flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(), rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState), reverseDirection: Boolean = false ): Modifier = rotaryHandler( - rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, null), + rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), reverseDirection = reverseDirection, rotaryHaptics = rotaryHaptics ) @@ -155,30 +166,26 @@ public fun Modifier.rotaryWithScroll( * A modifier which connects rotary events with scrollable. * This modifier supports snap. * - * The screen containing the scrollable item should request the focus - * by calling [requestFocus] method - * - * ``` - * LaunchedEffect(Unit) { - * focusRequester.requestFocus() - * } - * ``` - * @param focusRequester Requests the focus for rotary input + * @param focusRequester Requests the focus for rotary input. + * By default comes from [rememberActiveFocusRequester], + * which is used with [HierarchicalFocusCoordinator] * @param rotaryScrollAdapter A connection between scrollable objects and rotary events * @param rotaryHaptics Class which will handle haptic feedback * @param reverseDirection Reverse the direction of scrolling. Should be aligned with * Scrollable `reverseDirection` parameter */ +@OptIn(ExperimentalWearFoundationApi::class) @ExperimentalHorologistApi @Suppress("ComposableModifierFactory") @Composable public fun Modifier.rotaryWithSnap( - focusRequester: FocusRequester, rotaryScrollAdapter: RotaryScrollAdapter, + focusRequester: FocusRequester = rememberActiveFocusRequester(), + snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault(), rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState), reverseDirection: Boolean = false ): Modifier = rotaryHandler( - rotaryScrollHandler = RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter), + rotaryScrollHandler = RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters), reverseDirection = reverseDirection, rotaryHaptics = rotaryHaptics ) @@ -217,6 +224,11 @@ public class ScalingLazyColumnRotaryScrollAdapter( * An offset from the item centre */ override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat() + + /** + * The total count of items in ScalingLazyColumn + */ + override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount } /** @@ -248,6 +260,12 @@ public interface RotaryScrollAdapter { */ @ExperimentalHorologistApi public fun currentItemOffset(): Float + + /** + * The total count of items in [scrollableState] + */ + @ExperimentalHorologistApi + public fun totalItemsCount(): Int } /** @@ -307,15 +325,33 @@ public object RotaryDefaults { @Composable public fun rememberSnapHandler( rotaryScrollAdapter: RotaryScrollAdapter, - snapParameters: SnapParameters = snapParametersDefault() + snapParameters: SnapParameters = snapParametersDefault(), + isLowRes: Boolean = isLowResInput() ): RotaryScrollHandler { return remember(rotaryScrollAdapter, snapParameters) { - RotaryScrollSnapHandler( - snapBehaviourFactory = { - DefaultSnapBehavior(rotaryScrollAdapter, snapParameters) - }, - scrollBehaviourFactory = { AnimationScrollBehavior(rotaryScrollAdapter.scrollableState) } - ) + 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) + } + ) + } } } @@ -323,7 +359,12 @@ public object RotaryDefaults { * Returns default [SnapParameters] */ @ExperimentalHorologistApi - public fun snapParametersDefault(): SnapParameters = SnapParameters(snapOffset = 0) + 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). @@ -343,7 +384,11 @@ public object RotaryDefaults { * @param snapOffset an optional offset to be applied when snapping the item. After the snap the * snapped items offset will be [snapOffset]. */ -public class SnapParameters(public val snapOffset: Int) { +public class SnapParameters( + public val snapOffset: Int, + public val thresholdDivider: Float, + public val resistanceFactor: Float +) { /** * Returns a snapping offset in [Dp] */ @@ -500,7 +545,7 @@ public interface RotaryFlingBehavior { public interface RotarySnapBehavior { /** - * Preparing snapping. This method should be called before [startSnappingSession] is called. + * Preparing snapping. This method should be called before [snapToTargetItem] is called. * * Snapping is done for current + [moveForElements] items. * @@ -513,22 +558,24 @@ public interface RotarySnapBehavior { public fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) /** - * Performs snapping to the specified in [prepareSnapForItems] element - * If [toClosestItem] is true - then the snapping will happen to the closest item only. - * If it's set to false - then it'll snap to the element specified - * in [prepareSnapForItems] method. + * Performs snapping to the closest item. */ - @ExperimentalHorologistApi - public suspend fun startSnappingSession(toClosestItem: Boolean) + public suspend fun snapToClosestItem() /** - * A threshold after which snapping happens. - * There can be 2 thresholds - before snap and during snap (while snap is happening ). - * During-snap threshold is usually longer than before-snap so that - * the list will not scroll too fast. + * Returns true if top edge was reached */ - @ExperimentalHorologistApi - public fun snapThreshold(duringSnap: Boolean): Float + 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() } /** @@ -578,7 +625,7 @@ public class DefaultSnapBehavior( private val rotaryScrollAdapter: RotaryScrollAdapter, private val snapParameters: SnapParameters ) : RotarySnapBehavior { - private var snapTarget: Int = 0 + private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex() private var sequentialSnap: Boolean = false private var anim = AnimationState(0f) @@ -596,26 +643,10 @@ public class DefaultSnapBehavior( snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements } snapTargetUpdated = true + snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount()) } - @ExperimentalHorologistApi - override suspend fun startSnappingSession(toClosestItem: Boolean) { - if (toClosestItem) { - snapToClosestItem() - } else { - snapToAnotherItem() - } - } - - @ExperimentalHorologistApi - override fun snapThreshold(duringSnap: Boolean): Float { - val averageSize = rotaryScrollAdapter.averageItemSize() - // it just looks better if it takes more scroll to trigger a snap second time. - return (if (duringSnap) averageSize * 0.7f else averageSize * 0.3f) - .coerceIn(50f..400f) // 30 percent of the average height - } - - private suspend fun snapToClosestItem() { + override 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" } @@ -632,13 +663,17 @@ public class DefaultSnapBehavior( } } - private suspend fun snapToAnotherItem() { + override fun topEdgeReached(): Boolean = snapTarget <= 0 + + override fun bottomEdgeReached(): Boolean = + snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1 + + override suspend fun snapToTargetItem() { if (sequentialSnap) { anim = anim.copy(0f) } else { anim = AnimationState(0f) } - debugLog { "snapTarget $snapTarget" } rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) { // If snapTargetUpdated is true - then the target was updated so we // need to do snap again @@ -646,6 +681,7 @@ public class DefaultSnapBehavior( snapTargetUpdated = false var latestCenterItem: Int var continueFirstScroll = true + debugLog { "snapTarget $snapTarget" } while (continueFirstScroll) { latestCenterItem = rotaryScrollAdapter.currentItemIndex() anim = anim.copy(0f) @@ -741,7 +777,9 @@ public class DefaultSnapBehavior( @OptIn(ExperimentalComposeUiApi::class) public fun Modifier.rotaryHandler( rotaryScrollHandler: RotaryScrollHandler, - batchTimeframe: Long = 0L, + // TODO: batching causes additional delays. Return once it's clear that + // we will use it + /* batchTimeframe: Long = 0L,*/ reverseDirection: Boolean, rotaryHaptics: RotaryHapticHandler ): Modifier = composed { @@ -751,9 +789,10 @@ public fun Modifier.rotaryHandler( composed { LaunchedEffect(eventsFlow) { eventsFlow - // TODO: batching causes additional delays. + // 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) +// .batchRequestsWithinTimeframe(batchTimeframe) .collectLatest { debugLog { "Scroll event received: " + @@ -874,13 +913,11 @@ internal class HighResRotaryScrollHandler( if (rotaryFlingBehavior != null) { flingJob.cancel() flingJob = coroutineScope.async { - rotaryFlingBehavior?.trackFling( - beforeFling = { - debugLog { "Calling before fling section" } - scrollJob.cancel() - scrollBehavior = scrollBehaviorFactory() - } - ) + rotaryFlingBehavior?.trackFling(beforeFling = { + debugLog { "Calling before fling section" } + scrollJob.cancel() + scrollBehavior = scrollBehaviorFactory() + }) } } } @@ -976,23 +1013,29 @@ internal class LowResRotaryScrollHandler( * * This scroll handler doesn't support fling. */ -internal class RotaryScrollSnapHandler( - val snapBehaviourFactory: () -> RotarySnapBehavior, - val scrollBehaviourFactory: () -> RotaryScrollBehavior +internal class HighResSnapHandler( + private val resistanceFactor: Float, + private val thresholdBehaviorFactory: () -> ThresholdBehavior, + private val snapBehaviourFactory: () -> RotarySnapBehavior, + private val scrollBehaviourFactory: () -> RotaryScrollBehavior ) : RotaryScrollHandler { - // This constant is specific for high-res devices. Because that input values - // can sometimes come with different sign, we have to filter them in this threshold - val gestureThresholdTime = 200L - val snapDelay = 100L - var scrollJob: Job = CompletableDeferred() - var snapJob: Job = CompletableDeferred() + private val gestureThresholdTime = 200L + private val snapDelay = 100L + private val maxSnapsPerEvent = 2 - var previousScrollEventTime = 0L - var rotaryScrollDistance = 0f - var scrollInProgress = false + private var scrollJob: Job = CompletableDeferred() + private var snapJob: Job = CompletableDeferred() + + private var previousScrollEventTime = 0L + private var snapAccumulator = 0f + private var rotaryScrollDistance = 0f + private var scrollInProgress = false + + private var snapBehaviour = snapBehaviourFactory() + private var scrollBehaviour = scrollBehaviourFactory() + private var thresholdBehavior = thresholdBehaviorFactory() - var snapBehaviour = snapBehaviourFactory() - var scrollBehaviour = scrollBehaviourFactory() + private val scrollEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f) override suspend fun handleScrollEvent( coroutineScope: CoroutineScope, @@ -1007,40 +1050,62 @@ internal class RotaryScrollSnapHandler( snapJob.cancel() snapBehaviour = snapBehaviourFactory() scrollBehaviour = scrollBehaviourFactory() - rotaryScrollDistance = event.delta + thresholdBehavior = thresholdBehaviorFactory() + thresholdBehavior.startThresholdTracking(time) + snapAccumulator = 0f + rotaryScrollDistance = 0f + } + + if (!isOppositeValueAfterScroll(event.delta)) { + thresholdBehavior.observeEvent(event.timestamp, event.delta) } else { - // Filter out opposite axis values from end of scroll, also some values - // at the start of motion which sometimes appear with a different sign - if (isOppositeValueAfterScroll(event.delta)) { - debugLog { "Opposite value after scroll. Filtering:${event.delta}" } - return - } - rotaryScrollDistance += event.delta + debugLog { "Opposite value after scroll :${event.delta}" } } + + thresholdBehavior.applySmoothing() + val snapThreshold = thresholdBehavior.snapThreshold() + + snapAccumulator += event.delta + if (!snapJob.isActive) { + val resistanceCoeff = + 1 - scrollEasing.transform(rotaryScrollDistance.absoluteValue / snapThreshold) + rotaryScrollDistance += event.delta * resistanceCoeff + } + + debugLog { "Snap accumulator: $snapAccumulator" } debugLog { "Rotary scroll distance: $rotaryScrollDistance" } + + debugLog { "snapThreshold: $snapThreshold" } previousScrollEventTime = time - if (abs(rotaryScrollDistance) > snapBehaviour.snapThreshold(snapJob.isActive)) { - debugLog { "Snap threshold reached" } + if (abs(snapAccumulator) > snapThreshold) { scrollInProgress = false scrollBehaviour = scrollBehaviourFactory() scrollJob.cancel() - val snapDistance = sign(rotaryScrollDistance).toInt() - rotaryHaptics.handleSnapHaptic(event.delta) - + val snapDistance = (snapAccumulator / snapThreshold).toInt() + .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent) + snapAccumulator -= snapThreshold * snapDistance val sequentialSnap = snapJob.isActive + debugLog { - "Prepare snap: snapDistance:$snapDistance, " + - "sequentialSnap: $sequentialSnap" + "Snap threshold reached: snapDistance:$snapDistance, " + + "sequentialSnap: $sequentialSnap, " + + "snap accumulator remaining: $snapAccumulator" } + if ((!snapBehaviour.topEdgeReached() && snapDistance < 0) || + (!snapBehaviour.bottomEdgeReached() && snapDistance > 0) + ) { + rotaryHaptics.handleSnapHaptic(event.delta) + } + snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap) if (!snapJob.isActive) { snapJob.cancel() snapJob = coroutineScope.async { debugLog { "Snap started" } try { - snapBehaviour.startSnappingSession(false) + snapBehaviour.snapToTargetItem() } finally { debugLog { "Snap called finally" } } @@ -1050,15 +1115,21 @@ internal class RotaryScrollSnapHandler( } else { if (!snapJob.isActive) { scrollJob.cancel() - debugLog { "Scrolling for ${event.delta} px" } + debugLog { "Scrolling for $rotaryScrollDistance/$resistanceFactor px" } scrollJob = coroutineScope.async { - scrollBehaviour.handleEvent(rotaryScrollDistance) + scrollBehaviour.handleEvent(rotaryScrollDistance / resistanceFactor) } delay(snapDelay) scrollInProgress = false scrollBehaviour = scrollBehaviourFactory() + rotaryScrollDistance = 0f + snapAccumulator = 0f snapBehaviour.prepareSnapForItems(0, false) - snapBehaviour.startSnappingSession(true) + + snapJob.cancel() + snapJob = coroutineScope.async { + snapBehaviour.snapToClosestItem() + } } } } @@ -1077,8 +1148,147 @@ internal class RotaryScrollSnapHandler( } } -@Composable -private fun rememberTimestampChannel() = - remember { - Channel(capacity = Channel.CONFLATED) +/** + * A scroll handler for RSB(high-res) with snapping and without fling + * Snapping happens after a threshold is reached ( set in [RotarySnapBehavior]) + * + * This scroll handler doesn't support fling. + */ +internal class LowResSnapHandler( + private val snapBehaviourFactory: () -> RotarySnapBehavior +) : RotaryScrollHandler { + private val gestureThresholdTime = 200L + + private var snapJob: Job = CompletableDeferred() + + private var previousScrollEventTime = 0L + private var snapAccumulator = 0f + private var scrollInProgress = false + + private var snapBehaviour = snapBehaviourFactory() + + override suspend fun handleScrollEvent( + coroutineScope: CoroutineScope, + event: TimestampedDelta, + rotaryHaptics: RotaryHapticHandler + ) { + val time = event.timestamp + + if (isNewScrollEvent(time)) { + debugLog { "New scroll event" } + resetTracking() + snapJob.cancel() + snapBehaviour = snapBehaviourFactory() + snapAccumulator = 0f + } + + snapAccumulator += event.delta + + debugLog { "Snap accumulator: $snapAccumulator" } + + previousScrollEventTime = time + + if (abs(snapAccumulator) > 1f) { + scrollInProgress = false + + val snapDistance = sign(snapAccumulator).toInt() + rotaryHaptics.handleSnapHaptic(event.delta) + val sequentialSnap = snapJob.isActive + debugLog { + "Snap threshold reached: snapDistance:$snapDistance, " + + "sequentialSnap: $sequentialSnap, " + + "snap accumulator: $snapAccumulator" + } + + snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap) + if (!snapJob.isActive) { + snapJob.cancel() + snapJob = coroutineScope.async { + debugLog { "Snap started" } + try { + snapBehaviour.snapToTargetItem() + } finally { + debugLog { "Snap called finally" } + } + } + } + snapAccumulator = 0f + } + } + + private fun isNewScrollEvent(timestamp: Long): Boolean { + val timeDelta = timestamp - previousScrollEventTime + return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime + } + + private fun resetTracking() { + scrollInProgress = true + } +} + +internal class ThresholdBehavior( + private val rotaryScrollAdapter: RotaryScrollAdapter, + private val thresholdDivider: Float, + private val minVelocity: Float = 300f, + private val maxVelocity: Float = 3000f, + private val smoothingConstant: Float = 0.4f +) { + private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f) + + private val rotaryVelocityTracker = RotaryVelocityTracker() + + private var smoothedVelocity = 0f + fun startThresholdTracking(time: Long) { + rotaryVelocityTracker.start(time) + smoothedVelocity = 0f + } + + fun observeEvent(timestamp: Long, delta: Float) { + rotaryVelocityTracker.move(timestamp, delta) + } + + 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" } } + + fun snapThreshold(): Float { + val thresholdDividerFraction = + thresholdDividerEasing.transform( + inverseLerp( + minVelocity, + maxVelocity, + smoothedVelocity + ) + ) + return rotaryScrollAdapter.averageItemSize() / lerp( + 1f, + thresholdDivider, + thresholdDividerFraction + ) + } + + private fun exponentialSmoothing( + currentVelocity: Float, + prevVelocity: Float, + smoothingConstant: Float + ): Float = + smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity +} + +@Composable +private fun rememberTimestampChannel() = remember { + Channel(capacity = Channel.CONFLATED) +} + +private fun inverseLerp(start: Float, stop: Float, value: Float): Float { + return ((value - start) / (stop - start)).coerceIn(0f, 1f) +} diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/RotaryInputAccumulator.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/RotaryInputAccumulator.kt index c64274f1cc..a3f9024690 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/RotaryInputAccumulator.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/rotaryinput/RotaryInputAccumulator.kt @@ -69,7 +69,7 @@ internal class RotaryInputAccumulator( 1f } else if (isLowRes && scrollPixels < 0f) { // For negative tick in low res devices -1f - } else if (isLowRes /** && scrollPixels == 0f **/) { // For no tick in low res devices + } else if (isLowRes) { // && scrollPixels == 0f // For no tick in low res devices 0f } else { // Take it as is for high res devices scrollPixels diff --git a/compose-layout/src/test/java/com/google/android/horologist/compose/navscaffold/NavScaffoldTest.kt b/compose-layout/src/test/java/com/google/android/horologist/compose/navscaffold/NavScaffoldTest.kt index fb3129576a..0211bca00b 100644 --- a/compose-layout/src/test/java/com/google/android/horologist/compose/navscaffold/NavScaffoldTest.kt +++ b/compose-layout/src/test/java/com/google/android/horologist/compose/navscaffold/NavScaffoldTest.kt @@ -15,8 +15,7 @@ */ @file:OptIn( - ExperimentalCoroutinesApi::class, - ExperimentalWearFoundationApi::class + ExperimentalCoroutinesApi::class ) package com.google.android.horologist.compose.navscaffold @@ -27,10 +26,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -44,8 +41,6 @@ import androidx.lifecycle.get import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.test.filters.MediumTest -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.RequestFocusWhenActive import androidx.wear.compose.foundation.curvedComposable import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.ScalingLazyListState @@ -54,7 +49,7 @@ import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -100,13 +95,10 @@ class NavScaffoldTest { route = route, scrollStateBuilder = { scrollState } ) { - val focusRequester = - remember { FocusRequester() } ScalingLazyColumn( modifier = Modifier .fillMaxWidth() - .rotaryWithFling( - focusRequester, + .rotaryWithScroll( it.scrollableState ), state = it.scrollableState, @@ -116,8 +108,6 @@ class NavScaffoldTest { Text(text = "Item $it") } } - - RequestFocusWhenActive(focusRequester) } } @@ -198,20 +188,17 @@ class NavScaffoldTest { route = "b", scrollStateBuilder = { ScrollState(0) } ) { - val focusRequester = - remember { FocusRequester() } Column( modifier = Modifier .testTag("columnb") .fillMaxSize() - .rotaryWithFling(focusRequester, it.scrollableState) + .rotaryWithScroll(it.scrollableState) .verticalScroll(it.scrollableState) ) { (1..100).forEach { i -> Text("$i") } } - RequestFocusWhenActive(focusRequester) } } } diff --git a/compose-layout/src/test/java/com/google/android/horologist/compose/pager/PagerScreenTest.kt b/compose-layout/src/test/java/com/google/android/horologist/compose/pager/PagerScreenTest.kt index d937138174..ec47c39515 100644 --- a/compose-layout/src/test/java/com/google/android/horologist/compose/pager/PagerScreenTest.kt +++ b/compose-layout/src/test/java/com/google/android/horologist/compose/pager/PagerScreenTest.kt @@ -16,8 +16,7 @@ @file:OptIn( ExperimentalCoroutinesApi::class, - ExperimentalFoundationApi::class, - ExperimentalWearFoundationApi::class + ExperimentalFoundationApi::class ) package com.google.android.horologist.compose.pager @@ -30,9 +29,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused @@ -41,10 +38,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onParent import androidx.test.filters.MediumTest -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.RequestFocusWhenActive import androidx.wear.compose.material.Text -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -77,18 +72,16 @@ class PagerScreenTest { 5 } PagerScreen(modifier = Modifier.fillMaxSize(), state = pagerState) { i -> - val focusRequester = remember { FocusRequester() } val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxSize() - .rotaryWithFling(focusRequester, scrollState) + .rotaryWithScroll(scrollState) .verticalScroll(scrollState), verticalArrangement = Arrangement.Center ) { Text(modifier = Modifier.testTag("text$i"), text = "Text $i") } - RequestFocusWhenActive(focusRequester) } } diff --git a/sample/src/main/java/com/google/android/horologist/navsample/FillerScreen.kt b/sample/src/main/java/com/google/android/horologist/navsample/FillerScreen.kt index 9b25610db1..6d1ce2fe26 100644 --- a/sample/src/main/java/com/google/android/horologist/navsample/FillerScreen.kt +++ b/sample/src/main/java/com/google/android/horologist/navsample/FillerScreen.kt @@ -32,9 +32,8 @@ import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.ScalingLazyListState -import androidx.wear.compose.foundation.rememberActiveFocusRequester import androidx.wear.compose.material.Text -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll @Composable fun FillerScreen(label: String, modifier: Modifier = Modifier) { @@ -48,12 +47,10 @@ fun BigScalingLazyColumn( scrollState: ScalingLazyListState, modifier: Modifier = Modifier ) { - val focusRequester = rememberActiveFocusRequester() - ScalingLazyColumn( modifier = modifier .fillMaxSize() - .rotaryWithFling(focusRequester, scrollState), + .rotaryWithScroll(scrollState), state = scrollState, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -68,13 +65,11 @@ fun BigColumn( scrollState: ScrollState, modifier: Modifier = Modifier ) { - val focusRequester = rememberActiveFocusRequester() - Column( modifier = modifier .fillMaxSize() .verticalScroll(scrollState) - .rotaryWithFling(focusRequester, scrollState), + .rotaryWithScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.size(30.dp)) diff --git a/sample/src/main/java/com/google/android/horologist/navsample/NavMenuScreen.kt b/sample/src/main/java/com/google/android/horologist/navsample/NavMenuScreen.kt index d5d4381fda..56bbbcd0be 100644 --- a/sample/src/main/java/com/google/android/horologist/navsample/NavMenuScreen.kt +++ b/sample/src/main/java/com/google/android/horologist/navsample/NavMenuScreen.kt @@ -14,20 +14,16 @@ * limitations under the License. */ -@file:OptIn(ExperimentalWearFoundationApi::class) - package com.google.android.horologist.navsample import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.lazy.AutoCenteringParams import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.ScalingLazyListState -import androidx.wear.compose.foundation.rememberActiveFocusRequester -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import com.google.android.horologist.sample.SampleChip @Composable @@ -36,10 +32,8 @@ fun NavMenuScreen( navigateToRoute: (String) -> Unit, scrollState: ScalingLazyListState ) { - val focusRequester = rememberActiveFocusRequester() - ScalingLazyColumn( - modifier = modifier.fillMaxSize().rotaryWithFling(focusRequester, scrollState), + modifier = modifier.fillMaxSize().rotaryWithScroll(scrollState), state = scrollState, horizontalAlignment = Alignment.CenterHorizontally, autoCentering = AutoCenteringParams(itemIndex = 0) diff --git a/sample/src/main/java/com/google/android/horologist/rotary/RotaryScrollMenuList.kt b/sample/src/main/java/com/google/android/horologist/rotary/RotaryScrollMenuList.kt index cd90dc87e7..151c022457 100644 --- a/sample/src/main/java/com/google/android/horologist/rotary/RotaryScrollMenuList.kt +++ b/sample/src/main/java/com/google/android/horologist/rotary/RotaryScrollMenuList.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -@file:OptIn(ExperimentalWearFoundationApi::class) +@file:OptIn(ExperimentalWearFoundationApi::class, ExperimentalHorologistApi::class) package com.google.android.horologist.rotary import androidx.compose.foundation.background -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,7 +37,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -58,12 +56,12 @@ import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.CompactChip import androidx.wear.compose.material.Text +import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.composables.SectionedList import com.google.android.horologist.compose.layout.ScalingLazyColumnState import com.google.android.horologist.compose.material.Title import com.google.android.horologist.compose.rotaryinput.rememberDisabledHaptic import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import com.google.android.horologist.compose.rotaryinput.rotaryWithSnap import com.google.android.horologist.compose.rotaryinput.toRotaryScrollAdapter @@ -182,28 +180,23 @@ fun RotaryScrollWithFlingOrSnapScreen( val rotaryHapticHandler = if (hapticsEnabled) rememberRotaryHapticHandler(scalingLazyListState) else rememberDisabledHaptic() - val focusRequester = rememberActiveFocusRequester() ItemsListWithModifier( modifier = Modifier .let { if (isSnap) it.rotaryWithSnap( - focusRequester, scalingLazyListState.toRotaryScrollAdapter(), - rotaryHapticHandler + rotaryHaptics = rotaryHapticHandler ) - else if (isFling) it.rotaryWithFling( - focusRequester = focusRequester, + else if (isFling) it.rotaryWithScroll( scrollableState = scalingLazyListState, rotaryHaptics = rotaryHapticHandler ) else it.rotaryWithScroll( - focusRequester = focusRequester, scrollableState = scalingLazyListState, + flingBehavior = null, rotaryHaptics = rotaryHapticHandler ) - } - .focusRequester(focusRequester) - .focusable(), + }, scrollableState = scalingLazyListState ) { when (itemTypeIndex) { diff --git a/sample/src/main/java/com/google/android/horologist/sample/ScrollAwayScreen.kt b/sample/src/main/java/com/google/android/horologist/sample/ScrollAwayScreen.kt index e008472977..e063abf438 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/ScrollAwayScreen.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/ScrollAwayScreen.kt @@ -14,8 +14,6 @@ * limitations under the License. */ -@file:OptIn(ExperimentalWearFoundationApi::class) - package com.google.android.horologist.sample import androidx.compose.foundation.ScrollState @@ -33,8 +31,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.rememberActiveFocusRequester import androidx.wear.compose.material.Card import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator @@ -45,14 +41,12 @@ import androidx.wear.compose.material.scrollAway import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnState -import com.google.android.horologist.compose.rotaryinput.rotaryWithFling +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll @Composable fun ScrollScreenLazyColumn(scrollState: LazyListState) { - val focusRequester = rememberActiveFocusRequester() - LazyColumn( - modifier = Modifier.rotaryWithFling(focusRequester, scrollState), + modifier = Modifier.rotaryWithScroll(scrollState), state = scrollState ) { items(3) { i -> @@ -78,8 +72,6 @@ fun ScrollAwayScreenScalingLazyColumn( @Composable fun ScrollAwayScreenColumn(scrollState: ScrollState) { - val focusRequester = rememberActiveFocusRequester() - Scaffold( modifier = Modifier.fillMaxSize(), timeText = { @@ -91,7 +83,7 @@ fun ScrollAwayScreenColumn(scrollState: ScrollState) { ) { Column( modifier = Modifier - .rotaryWithFling(focusRequester, scrollState) + .rotaryWithScroll(scrollState) .verticalScroll(scrollState) ) { val modifier = Modifier.height(LocalConfiguration.current.screenHeightDp.dp / 2)