diff options
| author | 2024-02-20 22:50:43 +0000 | |
|---|---|---|
| committer | 2024-02-20 22:50:43 +0000 | |
| commit | fbb780ea2af3c9970fe82600fef9fd106467efbe (patch) | |
| tree | 0d834fba28a0242f7dee225a2cc5ff65a7690239 | |
| parent | 9634d62e806904197e61ffb2d74019cba18cb19a (diff) | |
| parent | 4c63af283f15fdea4d98cb7faf3805e733228787 (diff) | |
Merge "Enable rotary haptic for Wear" into main
4 files changed, 1533 insertions, 20 deletions
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt index 47b5e2ad1..ed4e937d1 100644 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt @@ -21,9 +21,6 @@ import android.content.Context import android.content.ContextWrapper import android.graphics.drawable.Drawable import androidx.compose.foundation.Image -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -34,22 +31,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.rotary.onRotaryScrollEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -71,8 +63,8 @@ import androidx.wear.compose.material.TimeText import androidx.wear.compose.material.Vignette import androidx.wear.compose.material.VignettePosition import androidx.wear.compose.material.scrollAway +import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithScroll import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme -import kotlinx.coroutines.launch /** * Screen that contains a list of items defined using the [content] parameter, adds the time text @@ -152,21 +144,16 @@ internal fun Scaffold( val focusRequester = remember { FocusRequester() } val listState = remember { ScalingLazyListState(initialCenterItemIndex = initialCenterIndex) } - val coroutineScope = rememberCoroutineScope() WearPermissionTheme { Scaffold( + // TODO: Use a rotary modifier from Wear Compose once Wear Compose 1.4 is landed. + // (b/325560444) modifier = - Modifier.onRotaryScrollEvent { - coroutineScope.launch { - listState.scrollBy(it.verticalScrollPixels) - listState.animateScrollBy(0f) - } - true - } - .focusRequester(focusRequester) - .focusable() - .semantics { testTagsAsResourceId = true }, + Modifier.rotaryWithScroll( + scrollableState = listState, + focusRequester = focusRequester + ), timeText = { if (showTimeText && !isLoading) { TimeText( diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt new file mode 100644 index 000000000..817bf7efe --- /dev/null +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt @@ -0,0 +1,292 @@ +/* + * Copyright (C) 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 com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput + +import android.os.Build +import android.view.View +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.platform.LocalView +import kotlin.math.abs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.withContext + +// This file is a copy of Haptics.kt from Horologist (go/horologist), +// remove it once Wear Compose 1.4 is landed (b/325560444). + +private const val DEBUG = false + +/** Debug logging that can be enabled. */ +private inline fun debugLog(generateMsg: () -> String) { + if (DEBUG) { + println("RotaryHaptics: ${generateMsg()}") + } +} + +/** + * Throttling events within specified timeframe. Only first and last events will be received. For a + * flow emitting elements 1 to 30, with a 100ms delay between them: + * ``` + * val flow = flow { + * for (i in 1..30) { + * delay(100) + * emit(i) + * } + * } + * ``` + * + * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 . + */ +internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> = flow { + conflate().collect { + emit(it) + delay(timeframe) + } +} + +/** Handles haptics for rotary usage */ +interface RotaryHapticHandler { + + /** Handles haptics when scroll is used */ + fun handleScrollHaptic(scrollDelta: Float) + + /** Handles haptics when scroll with snap is used */ + fun handleSnapHaptic(scrollDelta: Float) +} + +/** + * Default implementation of [RotaryHapticHandler]. It handles haptic feedback based on the + * [scrollableState], scrolled pixels and [hapticsThresholdPx]. Haptic is not fired in this class, + * instead it's sent to [hapticsChannel] where it'll performed later. + * + * @param scrollableState Haptic performed based on this state + * @param hapticsChannel Channel to which haptic events will be sent + * @param hapticsThresholdPx A scroll threshold after which haptic is produced. + */ +class DefaultRotaryHapticHandler( + private val scrollableState: ScrollableState, + private val hapticsChannel: Channel<RotaryHapticsType>, + private val hapticsThresholdPx: Long = 50, +) : RotaryHapticHandler { + + private var overscrollHapticTriggered = false + private var currScrollPosition = 0f + private var prevHapticsPosition = 0f + + override fun handleScrollHaptic(scrollDelta: Float) { + if ( + (scrollDelta > 0 && !scrollableState.canScrollForward) || + (scrollDelta < 0 && !scrollableState.canScrollBackward) + ) { + if (!overscrollHapticTriggered) { + trySendHaptic(RotaryHapticsType.ScrollLimit) + overscrollHapticTriggered = true + } + } else { + overscrollHapticTriggered = false + currScrollPosition += scrollDelta + val diff = abs(currScrollPosition - prevHapticsPosition) + + if (diff >= hapticsThresholdPx) { + trySendHaptic(RotaryHapticsType.ScrollTick) + prevHapticsPosition = currScrollPosition + } + } + } + + override fun handleSnapHaptic(scrollDelta: Float) { + if ( + (scrollDelta > 0 && !scrollableState.canScrollForward) || + (scrollDelta < 0 && !scrollableState.canScrollBackward) + ) { + if (!overscrollHapticTriggered) { + trySendHaptic(RotaryHapticsType.ScrollLimit) + overscrollHapticTriggered = true + } + } else { + overscrollHapticTriggered = false + trySendHaptic(RotaryHapticsType.ScrollItemFocus) + } + } + + private fun trySendHaptic(rotaryHapticsType: RotaryHapticsType) { + // Ok to ignore the ChannelResult because we default to capacity = 2 and DROP_OLDEST + @Suppress("UNUSED_VARIABLE") val unused = hapticsChannel.trySend(rotaryHapticsType) + } +} + +/** Interface for Rotary haptic feedback */ +interface RotaryHapticFeedback { + fun performHapticFeedback(type: RotaryHapticsType) +} + +/** Rotary haptic types */ +@JvmInline +value class RotaryHapticsType(private val type: Int) { + companion object { + /** + * A scroll ticking haptic. Similar to texture haptic - performed each time when a + * scrollable content is scrolled by a certain distance + */ + val ScrollTick: RotaryHapticsType = RotaryHapticsType(1) + + /** + * An item focus (snap) haptic. Performed when a scrollable content is snapped to a specific + * item. + */ + val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2) + + /** + * A limit(overscroll) haptic. Performed when a list reaches the limit (start or end) and + * can't scroll further + */ + val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3) + } +} + +/** Remember disabled haptics handler */ +@Composable +fun rememberDisabledHaptic(): RotaryHapticHandler = remember { + object : RotaryHapticHandler { + + override fun handleScrollHaptic(scrollDelta: Float) { + // Do nothing + } + + override fun handleSnapHaptic(scrollDelta: Float) { + // Do nothing + } + } +} + +/** + * Remember rotary haptic handler. + * + * @param scrollableState A scrollableState, used to determine whether the end of the scrollable was + * reached or not. + * @param throttleThresholdMs Throttling events within specified timeframe. Only first and last + * events will be received. Check [throttleLatest] for more info. + * @param hapticsThresholdPx A scroll threshold after which haptic is produced. + * @param hapticsChannel Channel to which haptic events will be sent + * @param rotaryHaptics Interface for Rotary haptic feedback which performs haptics + */ +@Composable +fun rememberRotaryHapticHandler( + scrollableState: ScrollableState, + throttleThresholdMs: Long = 30, + hapticsThresholdPx: Long = 50, + hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel(), + rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback(), +): RotaryHapticHandler { + return remember(scrollableState, hapticsChannel, rotaryHaptics) { + DefaultRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx) + } + .apply { + LaunchedEffect(hapticsChannel) { + hapticsChannel.receiveAsFlow().throttleLatest(throttleThresholdMs).collect { + hapticType -> + // 'withContext' launches performHapticFeedback in a separate thread, + // as otherwise it produces a visible lag (b/219776664) + val currentTime = System.currentTimeMillis() + debugLog { "Haptics started" } + withContext(Dispatchers.Default) { + debugLog { + "Performing haptics, delay: " + + "${System.currentTimeMillis() - currentTime}" + } + rotaryHaptics.performHapticFeedback(hapticType) + } + } + } + } +} + +@Composable +private fun rememberHapticChannel() = remember { + Channel<RotaryHapticsType>( + capacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) +} + +@Composable +public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback = + LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } } + +internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback = + if (isSamsungWatch()) { + SamsungWatchHapticFeedback(view) + } else { + DefaultRotaryHapticFeedback(view) + } + +/** Default Rotary implementation for [RotaryHapticFeedback] */ +class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback { + + override fun performHapticFeedback( + type: RotaryHapticsType, + ) { + when (type) { + RotaryHapticsType.ScrollItemFocus -> { + view.performHapticFeedback(SCROLL_ITEM_FOCUS) + } + RotaryHapticsType.ScrollTick -> { + view.performHapticFeedback(SCROLL_TICK) + } + RotaryHapticsType.ScrollLimit -> { + view.performHapticFeedback(SCROLL_LIMIT) + } + } + } + + private companion object { + // Hidden constants from HapticFeedbackConstants + const val SCROLL_TICK: Int = 18 + const val SCROLL_ITEM_FOCUS: Int = 19 + const val SCROLL_LIMIT: Int = 20 + } +} + +/** Implementation of [RotaryHapticFeedback] for Samsung devices */ +private class SamsungWatchHapticFeedback(private val view: View) : RotaryHapticFeedback { + override fun performHapticFeedback( + type: RotaryHapticsType, + ) { + when (type) { + RotaryHapticsType.ScrollItemFocus -> { + view.performHapticFeedback(102) + } + RotaryHapticsType.ScrollTick -> { + view.performHapticFeedback(102) + } + RotaryHapticsType.ScrollLimit -> { + view.performHapticFeedback(50107) + } + } + } +} + +private fun isSamsungWatch(): Boolean = Build.MANUFACTURER.contains("Samsung", ignoreCase = true) diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt new file mode 100644 index 000000000..b96bb3f5b --- /dev/null +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt @@ -0,0 +1,1187 @@ +/* + * Copyright (C) 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 com.android.permissioncontroller.permission.ui.wear.elements.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 +import androidx.compose.animation.core.copy +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.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.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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.util.lerp +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.transformLatest + +// This file is a copy of Rotary.kt from Horologist (go/horologist), +// remove it once Wear Compose 1.4 is landed (b/325560444). + +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. + * + * Fling algorithm: + * - A scroll with RSB/ Bezel happens. + * - If this is a first rotary event after the threshold ( by default 200ms), a new scroll session + * starts by resetting all necessary parameters + * - A delta value is added into VelocityTracker and a new speed is calculated. + * - If the current speed is bigger than the previous one, this value is remembered as a latest + * fling speed with a timestamp + * - After each scroll event a fling countdown starts ( by default 70ms) which resets if new scroll + * event is received + * - If fling countdown is finished - it means that the finger was probably raised from RSB, there + * will be no other events and probably this is the last event during this session. After it a + * fling is triggered. + * - Fling is stopped when a new scroll event happens + * + * 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 scrollableState Scrollable state which will be scrolled while receiving rotary events + * @param flingBehavior Logic describing fling behavior. + * @param rotaryHaptics Class which will handle haptic feedback + * @param reverseDirection Reverse the direction of scrolling. Should be aligned with Scrollable + * `reverseDirection` parameter + */ +@Suppress("ComposableModifierFactory") +@Deprecated( + "Use rotaryWithScroll instead", + ReplaceWith( + "this.rotaryWithScroll(scrollableState, focusRequester, " + + "flingBehavior, rotaryHaptics, reverseDirection)", + ), +) +@Composable +public fun Modifier.rotaryWithFling( + focusRequester: FocusRequester, + scrollableState: ScrollableState, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState), + reverseDirection: Boolean = false, +): Modifier = + rotaryHandler( + rotaryScrollHandler = + RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), + reverseDirection = reverseDirection, + rotaryHaptics = rotaryHaptics, + ) + .focusRequester(focusRequester) + .focusable() + +/** + * A modifier which connects rotary events with scrollable. This modifier supports scroll with + * fling. + * + * @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) +@Suppress("ComposableModifierFactory") +@Composable +public fun Modifier.rotaryWithScroll( + scrollableState: ScrollableState, + focusRequester: FocusRequester = rememberActiveFocusRequester(), + flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(), + rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState), + reverseDirection: Boolean = false, +): Modifier = + rotaryHandler( + rotaryScrollHandler = + RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), + reverseDirection = reverseDirection, + rotaryHaptics = rotaryHaptics, + ) + .focusRequester(focusRequester) + .focusable() + +/** + * A modifier which connects rotary events with scrollable. This modifier supports snap. + * + * @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) +@Suppress("ComposableModifierFactory") +@Composable +public fun Modifier.rotaryWithSnap( + rotaryScrollAdapter: RotaryScrollAdapter, + focusRequester: FocusRequester = rememberActiveFocusRequester(), + snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault(), + rotaryHaptics: RotaryHapticHandler = + rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState), + reverseDirection: Boolean = false, +): Modifier = + rotaryHandler( + rotaryScrollHandler = + RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters), + reverseDirection = reverseDirection, + rotaryHaptics = rotaryHaptics, + ) + .focusRequester(focusRequester) + .focusable() + +/** An adapter which connects scrollableState to Rotary */ +interface RotaryScrollAdapter { + + /** A scrollable state. Used for performing scroll when Rotary events received */ + val scrollableState: ScrollableState + + /** Average size of an item. Used for estimating the scrollable distance */ + fun averageItemSize(): Float + + /** A current item index. Used for scrolling */ + fun currentItemIndex(): Int + + /** An offset from the centre or the border of the current item. */ + fun currentItemOffset(): Float + + /** The total count of items in [scrollableState] */ + fun totalItemsCount(): Int +} + +/** Defaults for rotary modifiers */ +object RotaryDefaults { + + /** + * 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) + */ + @Composable + fun rememberFlingHandler( + scrollableState: ScrollableState, + flingBehavior: FlingBehavior? = null, + isLowRes: Boolean = isLowResInput(), + ): RotaryScrollHandler { + 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) + + if (isLowRes) { + LowResRotaryScrollHandler( + rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, + scrollBehaviorFactory = { scrollBehavior() }, + ) + } else { + HighResRotaryScrollHandler( + rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, + scrollBehaviorFactory = { scrollBehavior() }, + ) + } + } + } + + /** + * Handles scroll with snap + * + * @param rotaryScrollAdapter A connection between scrollable objects and rotary events + * @param snapParameters Snap parameters + */ + @Composable + fun rememberSnapHandler( + rotaryScrollAdapter: RotaryScrollAdapter, + 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) + }, + ) + } + } + } + + /** Returns default [SnapParameters] */ + 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). */ + @Composable + fun isLowResInput(): Boolean = + LocalContext.current.packageManager.hasSystemFeature( + "android.hardware.rotaryencoder.lowres" + ) + + private val lowResFlingTimeframe: Long = 100L + private val highResFlingTimeframe: Long = 30L +} + +/** + * Parameters used for snapping + * + * @param snapOffset an optional offset to be applied when snapping the item. After the snap the + * snapped items offset will be [snapOffset]. + */ +class SnapParameters( + val snapOffset: Int, + val thresholdDivider: Float, + val resistanceFactor: Float, +) { + /** Returns a snapping offset in [Dp] */ + @Composable + fun snapOffsetDp(): Dp { + return with(LocalDensity.current) { snapOffset.toDp() } + } +} + +/** An interface for handling scroll events */ +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 + */ + suspend fun handleScrollEvent( + coroutineScope: CoroutineScope, + event: TimestampedDelta, + rotaryHaptics: RotaryHapticHandler, + ) +} + +/** An interface for scrolling behavior */ +interface RotaryScrollBehavior { + /** Handles scroll event to [targetValue] */ + suspend fun handleEvent(targetValue: Float) +} + +/** Default implementation of [RotaryFlingBehavior] */ +class DefaultRotaryFlingBehavior( + 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] + 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 + + override fun startFlingTracking(timestamp: Long) { + rotaryVelocityTracker.start(timestamp) + latestEventTimestamp = timestamp + previousVelocity = 0f + } + + override fun observeEvent(timestamp: Long, delta: Float) { + rotaryVelocityTracker.move(timestamp, delta) + latestEventTimestamp = timestamp + } + + override suspend fun trackFling(beforeFling: () -> 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 + // `rangeToFling` 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 + ) { + // Stops scrollAnimationCoroutine because a fling will be performed + beforeFling() + val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed) + scrollableState.scroll(MutatePriority.UserInput) { + with(flingBehavior) { + debugLog { "Flinging with velocity $velocity" } + performFling(velocity) + } + } + } + } +} + +/** An interface for flinging with rotary */ +interface RotaryFlingBehavior { + + /** Observing new event within a fling tracking session with new timestamp and delta */ + fun observeEvent(timestamp: Long, delta: Float) + + /** Performing fling if necessary and calling [beforeFling] lambda before it is triggered */ + suspend fun trackFling(beforeFling: () -> Unit) + + /** Starts a new fling tracking session with specified timestamp */ + fun startFlingTracking(timestamp: Long) +} + +/** An interface for snapping with rotary */ +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. + */ + fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) + + /** Performs snapping to the closest item. */ + suspend fun snapToClosestItem() + + /** Returns true if top edge was reached */ + fun topEdgeReached(): Boolean + + /** Returns true if bottom edge was reached */ + fun bottomEdgeReached(): Boolean + + /** Performs snapping to the specified in [prepareSnapForItems] element */ + suspend fun snapToTargetItem() +} + +/** + * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta]. + */ +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 animation works well on Rsb(high-res) and Bezel(low-res) + * devices. + */ +class AnimationScrollBehavior( + private val scrollableState: ScrollableState, +) : RotaryScrollBehavior { + private var sequentialAnimation = false + private var scrollAnimation = AnimationState(0f) + private var prevPosition = 0f + + override suspend fun handleEvent(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 + } + } + } +} + +/** + * An animated implementation of [RotarySnapBehavior]. Uses animateScrollToItem method for snapping + * to the Nth item + */ +class DefaultSnapBehavior( + private val rotaryScrollAdapter: RotaryScrollAdapter, + private val snapParameters: SnapParameters, +) : RotarySnapBehavior { + private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex() + private var sequentialSnap: Boolean = false + + private var anim = AnimationState(0f) + private var expectedDistance = 0f + + private val defaultStiffness = 200f + private var snapTargetUpdated = true + + override fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) { + this.sequentialSnap = sequentialSnap + if (sequentialSnap) { + snapTarget += moveForElements + } else { + snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements + } + snapTargetUpdated = true + snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount()) + } + + 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" } + var prevPosition = 0f + AnimationState(0f).animateTo( + targetValue = -rotaryScrollAdapter.currentItemOffset(), + animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing), + ) { + val animDelta = value - prevPosition + scrollBy(animDelta) + prevPosition = value + } + snapTarget = rotaryScrollAdapter.currentItemIndex() + } + } + + 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) + } + rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) { + // If snapTargetUpdated is true - then the target was updated so we + // need to do snap again + while (snapTargetUpdated) { + snapTargetUpdated = false + var latestCenterItem: Int + var continueFirstScroll = true + debugLog { "snapTarget $snapTarget" } + while (continueFirstScroll) { + latestCenterItem = rotaryScrollAdapter.currentItemIndex() + anim = anim.copy(0f) + expectedDistance = expectedDistanceTo(snapTarget, snapParameters.snapOffset) + debugLog { + "expectedDistance = $expectedDistance, " + + "scrollableState.centerItemScrollOffset " + + "${rotaryScrollAdapter.currentItemOffset()}" + } + continueFirstScroll = false + var prevPosition = 0f + + anim.animateTo( + expectedDistance, + animationSpec = + SpringSpec( + stiffness = defaultStiffness, + visibilityThreshold = 0.1f, + ), + sequentialAnimation = (anim.velocity != 0f), + ) { + val animDelta = value - prevPosition + debugLog { + "First animation, value:$value, velocity:$velocity, " + + "animDelta:$animDelta" + } + + // Exit animation if snap target was updated + if (snapTargetUpdated) cancelAnimation() + + scrollBy(animDelta) + prevPosition = value + + if (latestCenterItem != rotaryScrollAdapter.currentItemIndex()) { + continueFirstScroll = true + cancelAnimation() + return@animateTo + } + + debugLog { "centerItemIndex = ${rotaryScrollAdapter.currentItemIndex()}" } + if (rotaryScrollAdapter.currentItemIndex() == snapTarget) { + debugLog { "Target is visible. Cancelling first animation" } + debugLog { + "scrollableState.centerItemScrollOffset " + + "${rotaryScrollAdapter.currentItemOffset()}" + } + expectedDistance = -rotaryScrollAdapter.currentItemOffset() + continueFirstScroll = false + cancelAnimation() + return@animateTo + } + } + } + // Exit animation if snap target was updated + if (snapTargetUpdated) continue + + anim = anim.copy(0f) + var prevPosition = 0f + anim.animateTo( + 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 = rotaryScrollAdapter.averageItemSize() + val indexesDiff = index - rotaryScrollAdapter.currentItemIndex() + debugLog { "Average size $averageSize" } + return (averageSize * indexesDiff) + targetScrollOffset - + rotaryScrollAdapter.currentItemOffset() + } +} + +/** + * A modifier which handles rotary events. It accepts ScrollHandler as the input - a class where + * main logic about how scroll should be handled is lying + */ +@OptIn(ExperimentalComposeUiApi::class) +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 + } + } +} + +/** + * Batching requests for scrolling events. This function combines all events together (except first) + * within specified timeframe. Should help with performance on high-res devices. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow<TimestampedDelta>.batchRequestsWithinTimeframe(timeframe: Long): Flow<TimestampedDelta> { + var delta = 0f + var lastTimestamp = -timeframe + return if (timeframe == 0L) { + this + } else { + this.transformLatest { + delta += it.delta + debugLog { "Batching requests. delta:$delta" } + if (lastTimestamp + timeframe <= it.timestamp) { + lastTimestamp = it.timestamp + debugLog { "No events before, delta= $delta" } + emit(TimestampedDelta(it.timestamp, delta)) + } else { + delay(timeframe) + debugLog { "After delay, delta= $delta" } + if (delta > 0f) { + emit(TimestampedDelta(it.timestamp, delta)) + } + } + delta = 0f + } + } +} + +/** + * A scroll handler for RSB(high-res) without snapping and with or without fling A list is scrolled + * by the number of pixels received from the rotary device. + * + * This class is a little bit different from LowResScrollHandler class - it has a filtering for + * events which are coming with wrong sign ( this happens to rsb devices, especially at the end of + * the scroll) + * + * This scroll handler supports fling. It can be set with [RotaryFlingBehavior]. + */ +internal class HighResRotaryScrollHandler( + private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?, + private val scrollBehaviorFactory: () -> RotaryScrollBehavior, + private val hapticsThreshold: Long = 50, +) : 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 + private val gestureThresholdTime = 200L + private var scrollJob: Job = CompletableDeferred<Unit>() + private var flingJob: Job = CompletableDeferred<Unit>() + + private var previousScrollEventTime = 0L + private var rotaryScrollDistance = 0f + + private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory() + private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory() + + override suspend fun handleScrollEvent( + coroutineScope: CoroutineScope, + event: TimestampedDelta, + rotaryHaptics: RotaryHapticHandler, + ) { + val time = event.timestamp + val isOppositeScrollValue = isOppositeValueAfterScroll(event.delta) + + if (isNewScrollEvent(time)) { + debugLog { "New scroll event" } + resetTracking(time) + rotaryScrollDistance = event.delta + } else { + // Due to the physics of 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 (!isOppositeScrollValue) { + rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta) + } else { + debugLog { "Opposite value after scroll :${event.delta}" } + } + rotaryScrollDistance += event.delta + } + + scrollJob.cancel() + + rotaryHaptics.handleScrollHaptic(event.delta) + debugLog { "Rotary scroll distance: $rotaryScrollDistance" } + + previousScrollEventTime = time + scrollJob = coroutineScope.async { scrollBehavior.handleEvent(rotaryScrollDistance) } + + if (rotaryFlingBehavior != null) { + flingJob.cancel() + flingJob = + coroutineScope.async { + rotaryFlingBehavior?.trackFling( + beforeFling = { + debugLog { "Calling before fling section" } + scrollJob.cancel() + scrollBehavior = scrollBehaviorFactory() + } + ) + } + } + } + + private fun isOppositeValueAfterScroll(delta: Float): Boolean = + sign(rotaryScrollDistance) * sign(delta) == -1f && (abs(delta) < abs(rotaryScrollDistance)) + + private fun isNewScrollEvent(timestamp: Long): Boolean { + val timeDelta = timestamp - previousScrollEventTime + return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime + } + + private fun resetTracking(timestamp: Long) { + scrollBehavior = scrollBehaviorFactory() + rotaryFlingBehavior = rotaryFlingBehaviorFactory() + rotaryFlingBehavior?.startFlingTracking(timestamp) + } +} + +/** + * A scroll handler for Bezel(low-res) without snapping. This scroll handler supports fling. It can + * be set with RotaryFlingBehavior. + */ +internal class LowResRotaryScrollHandler( + private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?, + private val scrollBehaviorFactory: () -> RotaryScrollBehavior, +) : RotaryScrollHandler { + + private val gestureThresholdTime = 200L + private var previousScrollEventTime = 0L + private var rotaryScrollDistance = 0f + + private var scrollJob: Job = CompletableDeferred<Unit>() + private var flingJob: Job = CompletableDeferred<Unit>() + + private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory() + private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory() + + override suspend fun handleScrollEvent( + coroutineScope: CoroutineScope, + event: TimestampedDelta, + rotaryHaptics: RotaryHapticHandler, + ) { + val time = event.timestamp + + if (isNewScrollEvent(time)) { + resetTracking(time) + rotaryScrollDistance = event.delta + } else { + rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta) + rotaryScrollDistance += event.delta + } + + scrollJob.cancel() + flingJob.cancel() + + rotaryHaptics.handleScrollHaptic(event.delta) + debugLog { "Rotary scroll distance: $rotaryScrollDistance" } + + previousScrollEventTime = time + scrollJob = coroutineScope.async { scrollBehavior.handleEvent(rotaryScrollDistance) } + + flingJob = + coroutineScope.async { + rotaryFlingBehavior?.trackFling( + beforeFling = { + debugLog { "Calling before fling section" } + scrollJob.cancel() + scrollBehavior = scrollBehaviorFactory() + }, + ) + } + } + + private fun isNewScrollEvent(timestamp: Long): Boolean { + val timeDelta = timestamp - previousScrollEventTime + return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime + } + + private fun resetTracking(timestamp: Long) { + scrollBehavior = scrollBehaviorFactory() + debugLog { "Velocity tracker reset" } + rotaryFlingBehavior = rotaryFlingBehaviorFactory() + rotaryFlingBehavior?.startFlingTracking(timestamp) + } +} + +/** + * 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 HighResSnapHandler( + private val resistanceFactor: Float, + private val thresholdBehaviorFactory: () -> ThresholdBehavior, + private val snapBehaviourFactory: () -> RotarySnapBehavior, + private val scrollBehaviourFactory: () -> RotaryScrollBehavior, +) : RotaryScrollHandler { + private val gestureThresholdTime = 200L + private val snapDelay = 100L + private val maxSnapsPerEvent = 2 + + private var scrollJob: Job = CompletableDeferred<Unit>() + private var snapJob: Job = CompletableDeferred<Unit>() + + 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() + + private val scrollEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f) + + 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() + scrollBehaviour = scrollBehaviourFactory() + thresholdBehavior = thresholdBehaviorFactory() + thresholdBehavior.startThresholdTracking(time) + snapAccumulator = 0f + rotaryScrollDistance = 0f + } + + if (!isOppositeValueAfterScroll(event.delta)) { + thresholdBehavior.observeEvent(event.timestamp, event.delta) + } else { + 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(snapAccumulator) > snapThreshold) { + scrollInProgress = false + scrollBehaviour = scrollBehaviourFactory() + scrollJob.cancel() + + val snapDistance = + (snapAccumulator / snapThreshold) + .toInt() + .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent) + snapAccumulator -= snapThreshold * snapDistance + val sequentialSnap = snapJob.isActive + + debugLog { + "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.snapToTargetItem() + } finally { + debugLog { "Snap called finally" } + } + } + } + rotaryScrollDistance = 0f + } else { + if (!snapJob.isActive) { + scrollJob.cancel() + debugLog { "Scrolling for $rotaryScrollDistance/$resistanceFactor px" } + scrollJob = + coroutineScope.async { + scrollBehaviour.handleEvent(rotaryScrollDistance / resistanceFactor) + } + delay(snapDelay) + scrollInProgress = false + scrollBehaviour = scrollBehaviourFactory() + rotaryScrollDistance = 0f + snapAccumulator = 0f + snapBehaviour.prepareSnapForItems(0, false) + + snapJob.cancel() + snapJob = coroutineScope.async { snapBehaviour.snapToClosestItem() } + } + } + } + + private fun isOppositeValueAfterScroll(delta: Float): Boolean = + sign(rotaryScrollDistance) * sign(delta) == -1f && (abs(delta) < abs(rotaryScrollDistance)) + + private fun isNewScrollEvent(timestamp: Long): Boolean { + val timeDelta = timestamp - previousScrollEventTime + return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime + } + + private fun resetTracking() { + scrollInProgress = true + } +} + +/** + * 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<Unit>() + + 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<TimestampedDelta>(capacity = Channel.CONFLATED) +} + +private fun inverseLerp(start: Float, stop: Float, value: Float): Float { + return ((value - start) / (stop - start)).coerceIn(0f, 1f) +} diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt new file mode 100644 index 000000000..1719ecef3 --- /dev/null +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 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 com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput + +import androidx.compose.ui.input.pointer.util.VelocityTracker1D + +// This file is a copy of RotaryVelocityTracker.kt from Horologist (go/horologist), +// remove it once Wear Compose 1.4 is landed (b/325560444). + +/** A wrapper around VelocityTracker1D to provide support for rotary input. */ +class RotaryVelocityTracker { + private var velocityTracker: VelocityTracker1D = VelocityTracker1D(true) + + /** Retrieve the last computed velocity. */ + val velocity: Float + get() = velocityTracker.calculateVelocity() + + /** Start tracking motion. */ + fun start(currentTime: Long) { + velocityTracker.resetTracking() + velocityTracker.addDataPoint(currentTime, 0f) + } + + /** Continue tracking motion as the input rotates. */ + fun move(currentTime: Long, delta: Float) { + velocityTracker.addDataPoint(currentTime, delta) + } + + /** Stop tracking motion. */ + fun end() { + velocityTracker.resetTracking() + } +} |