summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author vignesh ramanathan <vigneshrsastra@google.com> 2024-12-20 21:35:53 +0000
committer Vignesh Ramanathan <vigneshrsastra@google.com> 2025-01-03 06:32:20 -0800
commit33d746baf4b58e84e1c0986acb451c682a2ea85b (patch)
tree1fce55e9c6a74eb69950a4cbd5001f6a26e7e715
parent65feac4876b77f05ad640dfce38d952ea892a030 (diff)
Adding TransformingLazy Column
1. Adding TLC support for mat3 lists 2. SLC is still supported for mat3 screens 3. Custom Rotary modifier is removed in favor of androidx version. BUG: b/325560444, b/382535255 Relnote: "N/A" Test: Manual. Existing Test passes. LOW_COVERAGE_REASON=FLAG_NOT_ENABLED Change-Id: I60094a9af8f05b3aa9f3d2399fb5902933b5ce5f
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt16
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt58
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt86
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt292
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt1232
-rw-r--r--PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt47
6 files changed, 68 insertions, 1663 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 6ce7df125..b84f840cf 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
@@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
@@ -65,7 +66,6 @@ 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.material3.WearPermissionScaffold
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithScroll
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion.MATERIAL2_5
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme
@@ -86,7 +86,7 @@ fun ScrollableScreen(
isLoading: Boolean = false,
titleTestTag: String? = null,
subtitleTestTag: String? = null,
- content: ScalingLazyListScope.() -> Unit,
+ content: ListScopeWrapper.() -> Unit,
) {
var dismissed by remember { mutableStateOf(false) }
val activity = LocalContext.current.findActivity()
@@ -193,13 +193,7 @@ internal fun Wear2Scaffold(
}
WearPermissionTheme {
Scaffold(
- // TODO: Use a rotary modifier from Wear Compose once Wear Compose 1.4 is landed.
- // (b/325560444)
- modifier =
- Modifier.rotaryWithScroll(
- scrollableState = listState,
- focusRequester = focusRequester,
- ),
+ modifier = Modifier.focusRequester(focusRequester),
timeText = {
if (showTimeText && !isLoading) {
TimeText(
@@ -364,3 +358,7 @@ internal fun Context.findActivity(): Activity {
}
throw IllegalStateException("The screen should be called in the context of an Activity")
}
+
+interface ListScopeWrapper {
+ fun item(key: Any? = null, contentType: Any? = null, content: @Composable () -> Unit)
+}
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt
index 0603647b1..b3698a60c 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt
@@ -42,14 +42,8 @@ import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.foundation.lazy.ScalingParams
-import androidx.wear.compose.foundation.rememberActiveFocusRequester
import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnDefaults.responsiveScalingParams
import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnState.RotaryMode
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rememberDisabledHaptic
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rememberRotaryHapticHandler
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithScroll
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithSnap
-import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.toRotaryScrollAdapter
// This file is a copy of ScalingLazyColumnState.kt from Horologist (go/horologist),
// remove it once after wear compose supports large screen dialogs.
@@ -61,10 +55,7 @@ import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.
class ScalingLazyColumnState(
val initialScrollPosition: ScrollPosition = ScrollPosition(1, 0),
val autoCentering: AutoCenteringParams? =
- AutoCenteringParams(
- initialScrollPosition.index,
- initialScrollPosition.offsetPx,
- ),
+ AutoCenteringParams(initialScrollPosition.index, initialScrollPosition.offsetPx),
val anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
val contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
val rotaryMode: RotaryMode? = RotaryMode.Scroll,
@@ -120,10 +111,7 @@ class ScalingLazyColumnState(
data object Scroll : RotaryMode
}
- data class ScrollPosition(
- val index: Int,
- val offsetPx: Int,
- )
+ data class ScrollPosition(val index: Int, val offsetPx: Int)
fun interface Factory {
@Composable fun create(): ScalingLazyColumnState
@@ -133,7 +121,7 @@ class ScalingLazyColumnState(
// @Deprecated("Replaced by rememberResponsiveColumnState")
@Composable
fun rememberColumnState(
- factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.responsive(),
+ factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.responsive()
): ScalingLazyColumnState {
val columnState = factory.create()
@@ -150,10 +138,7 @@ fun rememberResponsiveColumnState(
last = ScalingLazyColumnDefaults.ItemType.Unspecified,
),
verticalArrangement: Arrangement.Vertical =
- Arrangement.spacedBy(
- space = 4.dp,
- alignment = Alignment.Top,
- ),
+ Arrangement.spacedBy(space = 4.dp, alignment = Alignment.Top),
rotaryMode: RotaryMode? = RotaryMode.Scroll,
hapticsEnabled: Boolean = true,
reverseLayout: Boolean = false,
@@ -173,10 +158,7 @@ fun rememberResponsiveColumnState(
val topScreenOffsetPx = screenHeightPx / 2 - topPaddingPx
val initialScrollPosition =
- ScalingLazyColumnState.ScrollPosition(
- index = 0,
- offsetPx = topScreenOffsetPx,
- )
+ ScalingLazyColumnState.ScrollPosition(index = 0, offsetPx = topScreenOffsetPx)
val columnState =
ScalingLazyColumnState(
@@ -204,36 +186,8 @@ fun ScalingLazyColumn(
modifier: Modifier = Modifier,
content: ScalingLazyListScope.() -> Unit,
) {
- val focusRequester = rememberActiveFocusRequester()
-
- val rotaryHaptics =
- if (columnState.hapticsEnabled) {
- rememberRotaryHapticHandler(columnState.state)
- } else {
- rememberDisabledHaptic()
- }
-
- val modifierWithRotary =
- when (columnState.rotaryMode) {
- RotaryMode.Snap ->
- modifier.rotaryWithSnap(
- focusRequester = focusRequester,
- rotaryScrollAdapter = columnState.state.toRotaryScrollAdapter(),
- reverseDirection = columnState.reverseLayout,
- rotaryHaptics = rotaryHaptics,
- )
- RotaryMode.Scroll ->
- modifier.rotaryWithScroll(
- focusRequester = focusRequester,
- scrollableState = columnState.state,
- reverseDirection = columnState.reverseLayout,
- rotaryHaptics = rotaryHaptics,
- )
- else -> modifier
- }
-
ScalingLazyColumn(
- modifier = modifierWithRotary.fillMaxSize(),
+ modifier = modifier.fillMaxSize(),
state = columnState.state,
contentPadding = columnState.contentPadding,
reverseLayout = columnState.reverseLayout,
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt
index 9a926f5a3..babd7fb6f 100644
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt
+++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt
@@ -17,6 +17,7 @@ package com.android.permissioncontroller.permission.ui.wear.elements.material3
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
@@ -37,24 +38,41 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.ScrollInfoProvider
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
+import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.CircularProgressIndicator
+import androidx.wear.compose.material3.IconButtonDefaults
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.ScrollIndicator
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TimeText
+import androidx.wear.compose.material3.lazy.scrollTransform
import com.android.permissioncontroller.permission.ui.wear.elements.AnnotatedText
+import com.android.permissioncontroller.permission.ui.wear.elements.ListScopeWrapper
import com.android.permissioncontroller.permission.ui.wear.elements.Wear2Scaffold
-import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumn
-import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnState
-import com.android.permissioncontroller.permission.ui.wear.elements.layout.rememberResponsiveColumnState
import com.android.permissioncontroller.permission.ui.wear.elements.rememberDrawablePainter
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion.MATERIAL2_5
import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme
+private class TransformingScopeConverter(private val scope: TransformingLazyColumnScope) :
+ ListScopeWrapper {
+ override fun item(key: Any?, contentType: Any?, content: @Composable () -> Unit) {
+ scope.item { Box(modifier = Modifier.scrollTransform(this)) { content() } }
+ }
+}
+
+private class ScalingScopeConverter(private val scope: ScalingLazyListScope) : ListScopeWrapper {
+ override fun item(key: Any?, contentType: Any?, content: @Composable () -> Unit) {
+ scope.item { content() }
+ }
+}
+
/**
* This component is wrapper on material scaffold component. It helps with time text, scroll
* indicator and standard list elements like title, icon and subtitle.
@@ -67,7 +85,7 @@ internal fun WearPermissionScaffold(
subtitle: CharSequence?,
image: Any?,
isLoading: Boolean,
- content: ScalingLazyListScope.() -> Unit,
+ content: ListScopeWrapper.() -> Unit,
titleTestTag: String? = null,
subtitleTestTag: String? = null,
) {
@@ -79,7 +97,7 @@ internal fun WearPermissionScaffold(
subtitle,
image,
isLoading,
- content,
+ { content.invoke(ScalingScopeConverter(this)) },
titleTestTag,
subtitleTestTag,
)
@@ -90,7 +108,7 @@ internal fun WearPermissionScaffold(
subtitle,
image,
isLoading,
- content,
+ { content.invoke(TransformingScopeConverter(this)) },
titleTestTag,
subtitleTestTag,
)
@@ -104,7 +122,7 @@ private fun WearPermissionScaffoldInternal(
subtitle: CharSequence?,
image: Any?,
isLoading: Boolean,
- content: ScalingLazyListScope.() -> Unit,
+ content: TransformingLazyColumnScope.() -> Unit,
titleTestTag: String? = null,
subtitleTestTag: String? = null,
) {
@@ -116,12 +134,11 @@ private fun WearPermissionScaffoldInternal(
screenHeight = screenHeight,
titleNeedsLargePadding = subtitle == null,
)
- val columnState =
- rememberResponsiveColumnState(contentPadding = { paddingDefaults.scrollContentPadding })
+ val columnState = rememberTransformingLazyColumnState()
WearPermissionTheme(version = WearPermissionMaterialUIVersion.MATERIAL3) {
AppScaffold(timeText = wearPermissionTimeText(showTimeText && !isLoading)) {
ScreenScaffold(
- scrollInfoProvider = ScrollInfoProvider(columnState.state),
+ scrollInfoProvider = ScrollInfoProvider(columnState),
scrollIndicator = wearPermissionScrollIndicator(!isLoading, columnState),
) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -129,6 +146,7 @@ private fun WearPermissionScaffoldInternal(
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
ScrollingView(
+ contentPadding = paddingDefaults.scrollContentPadding,
columnState = columnState,
icon = painterFromImage(image),
title = title,
@@ -184,7 +202,8 @@ private class WearPermissionScaffoldPaddingDefaults(
@Composable
private fun BoxScope.ScrollingView(
- columnState: ScalingLazyColumnState,
+ contentPadding: PaddingValues,
+ columnState: TransformingLazyColumnState,
icon: Painter?,
title: String?,
titleTestTag: String?,
@@ -192,16 +211,26 @@ private fun BoxScope.ScrollingView(
subtitleTestTag: String?,
titlePaddingValues: PaddingValues,
subTitlePaddingValues: PaddingValues,
- content: ScalingLazyListScope.() -> Unit,
+ content: TransformingLazyColumnScope.() -> Unit,
) {
- ScalingLazyColumn(columnState = columnState) {
- iconItem(icon, Modifier.size(24.dp))
- titleItem(text = title, testTag = titleTestTag, contentPaddingValues = titlePaddingValues)
- subtitleItem(
- text = subtitle,
- testTag = subtitleTestTag,
- modifier = Modifier.align(Alignment.Center).padding(subTitlePaddingValues),
- )
+ TransformingLazyColumn(
+ contentPadding = contentPadding,
+ state = columnState,
+ modifier = Modifier.background(MaterialTheme.colorScheme.background),
+ ) {
+ with(TransformingScopeConverter(this)) {
+ iconItem(icon, Modifier.size(IconButtonDefaults.LargeIconSize))
+ titleItem(
+ text = title,
+ testTag = titleTestTag,
+ contentPaddingValues = titlePaddingValues,
+ )
+ subtitleItem(
+ text = subtitle,
+ testTag = subtitleTestTag,
+ modifier = Modifier.align(Alignment.Center).padding(subTitlePaddingValues),
+ )
+ }
content()
}
}
@@ -216,15 +245,10 @@ private fun wearPermissionTimeText(showTime: Boolean): @Composable () -> Unit {
private fun wearPermissionScrollIndicator(
showIndicator: Boolean,
- columnState: ScalingLazyColumnState,
+ columnState: TransformingLazyColumnState,
): @Composable (BoxScope.() -> Unit)? {
return if (showIndicator) {
- {
- ScrollIndicator(
- modifier = Modifier.align(Alignment.CenterEnd),
- state = columnState.state,
- )
- }
+ { ScrollIndicator(modifier = Modifier.align(Alignment.CenterEnd), state = columnState) }
} else {
null
}
@@ -246,7 +270,7 @@ private fun Modifier.optionalTestTag(tag: String?): Modifier {
return this then testTag(tag)
}
-private fun ScalingLazyListScope.iconItem(painter: Painter?, modifier: Modifier = Modifier) =
+private fun ListScopeWrapper.iconItem(painter: Painter?, modifier: Modifier = Modifier) =
painter?.let {
item {
val iconColor = WearPermissionButtonStyle.Secondary.material3ButtonColors().iconColor
@@ -260,14 +284,14 @@ private fun ScalingLazyListScope.iconItem(painter: Painter?, modifier: Modifier
}
}
-private fun ScalingLazyListScope.titleItem(
+private fun ListScopeWrapper.titleItem(
text: String?,
testTag: String?,
contentPaddingValues: PaddingValues,
modifier: Modifier = Modifier,
) =
text?.let {
- item {
+ item(contentType = "header") {
ListHeader(
modifier = modifier.requiredHeightIn(1.dp), // We do not want default min height
contentPadding = contentPaddingValues,
@@ -281,7 +305,7 @@ private fun ScalingLazyListScope.titleItem(
}
}
-private fun ScalingLazyListScope.subtitleItem(
+private fun ListScopeWrapper.subtitleItem(
text: CharSequence?,
testTag: String?,
modifier: Modifier = Modifier,
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
deleted file mode 100644
index 817bf7efe..000000000
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt
+++ /dev/null
@@ -1,292 +0,0 @@
-/*
- * 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
deleted file mode 100644
index 19a6ea671..000000000
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt
+++ /dev/null
@@ -1,1232 +0,0 @@
-/*
- * 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.remember
-import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.input.rotary.RotaryInputModifierNode
-import androidx.compose.ui.input.rotary.RotaryScrollEvent
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.debugInspectorInfo
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.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 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
-import kotlinx.coroutines.launch
-
-// This file is a copy of Rotary.kt from Horologist (go/horologist),
-// remove it once Wear Compose 1.4 is landed (b/325560444).
-
-/**
- * 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
-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,
- inspectorInfo =
- debugInspectorInfo {
- name = "rotaryWithFling"
- properties["scrollableState"] = scrollableState
- properties["focusRequester"] = focusRequester
- properties["flingBehavior"] = flingBehavior
- properties["rotaryHaptics"] = rotaryHaptics
- properties["reverseDirection"] = reverseDirection
- },
- )
- .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
-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,
- inspectorInfo =
- debugInspectorInfo {
- name = "rotaryWithFling"
- properties["rotaryScrollAdapter"] = rotaryScrollAdapter
- properties["focusRequester"] = focusRequester
- properties["snapParameters"] = snapParameters
- properties["rotaryHaptics"] = rotaryHaptics
- properties["reverseDirection"] = reverseDirection
- },
- )
- .focusRequester(focusRequester)
- .focusable()
-
-/** An extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState] */
-@Composable
-fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter =
- remember(this) { ScalingLazyColumnRotaryScrollAdapter(this) }
-
-/** An implementation of rotary scroll adapter for [ScalingLazyColumn] */
-class ScalingLazyColumnRotaryScrollAdapter(
- override val scrollableState: ScalingLazyListState,
-) : RotaryScrollAdapter {
-
- /** Calculates an average height of an item by taking an average from visible items height. */
- override fun averageItemSize(): Float {
- val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
- return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
- }
-
- /** Current (centred) item index */
- override fun currentItemIndex(): Int = scrollableState.centerItemIndex
-
- /** 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
-}
-
-/** 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 {
-
- /** Returns default [SnapParameters] */
- val snapParametersDefault: SnapParameters =
- SnapParameters(
- snapOffset = 0,
- thresholdDivider = 1.5f,
- resistanceFactor = 3f,
- )
-
- /** Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb). */
- @Composable
- fun isLowResInput(): Boolean =
- LocalContext.current.packageManager.hasSystemFeature(
- "android.hardware.rotaryencoder.lowres"
- )
-
- /**
- * Handles scroll with fling.
- *
- * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
- * @param flingBehavior Logic describing Fling behavior. If null - fling will not happen
- * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
- */
- @Composable
- internal fun rememberFlingHandler(
- scrollableState: ScrollableState,
- flingBehavior: FlingBehavior? = null,
- isLowRes: Boolean = isLowResInput(),
- ): RotaryScrollHandler {
- val viewConfiguration = ViewConfiguration.get(LocalContext.current)
-
- return remember(scrollableState, flingBehavior, isLowRes) {
- // Remove unnecessary recompositions by disabling tracking of changes inside of
- // this block. This algorithm properly reads all updated values and
- // don't need recomposition when those values change.
- Snapshot.withoutReadObservation {
- debugLog { "isLowRes : $isLowRes" }
- fun rotaryFlingBehavior() =
- flingBehavior?.run {
- RotaryFlingBehavior(
- scrollableState,
- flingBehavior,
- viewConfiguration,
- flingTimeframe =
- if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe,
- )
- }
-
- fun scrollBehavior() = RotaryScrollBehavior(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
- internal fun rememberSnapHandler(
- rotaryScrollAdapter: RotaryScrollAdapter,
- snapParameters: SnapParameters = snapParametersDefault,
- isLowRes: Boolean = isLowResInput(),
- ): RotaryScrollHandler {
- return remember(rotaryScrollAdapter, snapParameters) {
- // Remove unnecessary recompositions by disabling tracking of changes inside of
- // this block. This algorithm properly reads all updated values and
- // don't need recomposition when those values change.
- Snapshot.withoutReadObservation {
- debugLog { "isLowRes : $isLowRes" }
- if (isLowRes) {
- LowResSnapHandler(
- snapBehaviourFactory = {
- RotarySnapBehavior(rotaryScrollAdapter, snapParameters)
- },
- )
- } else {
- HighResSnapHandler(
- resistanceFactor = snapParameters.resistanceFactor,
- thresholdBehaviorFactory = {
- ThresholdBehavior(
- rotaryScrollAdapter,
- snapParameters.thresholdDivider,
- )
- },
- snapBehaviourFactory = {
- RotarySnapBehavior(rotaryScrollAdapter, snapParameters)
- },
- scrollBehaviourFactory = {
- RotaryScrollBehavior(rotaryScrollAdapter.scrollableState)
- },
- )
- }
- }
- }
- }
-
- 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() }
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other == null || this::class != other::class) return false
-
- other as SnapParameters
-
- if (snapOffset != other.snapOffset) return false
- if (thresholdDivider != other.thresholdDivider) return false
- if (resistanceFactor != other.resistanceFactor) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = snapOffset
- result = 31 * result + thresholdDivider.hashCode()
- result = 31 * result + resistanceFactor.hashCode()
- return result
- }
-}
-
-/** An interface for handling scroll events */
-internal interface RotaryScrollHandler {
- /**
- * Handles scrolling events
- *
- * @param coroutineScope A scope for performing async actions
- * @param event A scrollable event from rotary input, containing scrollable delta and timestamp
- * @param rotaryHaptics
- */
- suspend fun handleScrollEvent(
- coroutineScope: CoroutineScope,
- event: TimestampedDelta,
- rotaryHaptics: RotaryHapticHandler,
- )
-}
-
-/**
- * Class responsible for Fling behaviour with rotary. It tracks and produces the fling when
- * necessary
- */
-internal class RotaryFlingBehavior(
- private val scrollableState: ScrollableState,
- private val flingBehavior: FlingBehavior,
- viewConfiguration: ViewConfiguration,
- private val flingTimeframe: Long,
-) {
-
- // A time range during which the fling is valid.
- // For simplicity it's twice as long as [flingTimeframe]
- private val timeRangeToFling = flingTimeframe * 2
-
- // A default fling factor for making fling slower
- private val flingScaleFactor = 0.7f
-
- private var previousVelocity = 0f
-
- private val rotaryVelocityTracker = RotaryVelocityTracker()
-
- private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat()
- private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat()
- private var latestEventTimestamp: Long = 0
-
- private var flingVelocity: Float = 0f
- private var flingTimestamp: Long = 0
-
- /** Starts a new fling tracking session with specified timestamp */
- fun startFlingTracking(timestamp: Long) {
- rotaryVelocityTracker.start(timestamp)
- latestEventTimestamp = timestamp
- previousVelocity = 0f
- }
-
- /** Observing new event within a fling tracking session with new timestamp and delta */
- fun observeEvent(timestamp: Long, delta: Float) {
- rotaryVelocityTracker.move(timestamp, delta)
- latestEventTimestamp = timestamp
- }
-
- /** Performing fling if necessary and calling [beforeFling] lambda before it is triggered */
- 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)
- }
- }
- }
- }
-}
-
-/**
- * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta].
- */
-internal data class TimestampedDelta(val timestamp: Long, val delta: Float)
-
-/**
- * This class does a smooth animation when the scroll by N pixels is done. This animation works well
- * on Rsb(high-res) and Bezel(low-res) devices.
- */
-internal class RotaryScrollBehavior(
- private val scrollableState: ScrollableState,
-) {
- private var sequentialAnimation = false
- private var scrollAnimation = AnimationState(0f)
- private var prevPosition = 0f
-
- /** Handles scroll event to [targetValue] */
- 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
- }
- }
- }
-}
-
-/**
- * A helper class for snapping with rotary. Uses animateScrollToItem method for snapping to the Nth
- * item.
- */
-internal class RotarySnapBehavior(
- private val rotaryScrollAdapter: RotaryScrollAdapter,
- private val snapParameters: SnapParameters,
-) {
- 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
-
- /**
- * Preparing snapping. This method should be called before [snapToTargetItem] is called.
- *
- * Snapping is done for current + [moveForElements] items.
- *
- * If [sequentialSnap] is true, items are summed up together. For example, if
- * [prepareSnapForItems] is called with [moveForElements] = 2, 3, 5 -> then the snapping will
- * happen to current + 10 items
- *
- * If [sequentialSnap] is false, then [moveForElements] are not summed up together.
- */
- fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) {
- this.sequentialSnap = sequentialSnap
- if (sequentialSnap) {
- snapTarget += moveForElements
- } else {
- snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements
- }
- snapTargetUpdated = true
- snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount())
- }
-
- /** Performs snapping to the closest item. */
- suspend fun snapToClosestItem() {
- // Snapping to the closest item by using performFling method with 0 speed
- rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
- debugLog { "snap to closest item" }
- 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()
- }
- }
-
- /** Returns true if top edge was reached */
- fun topEdgeReached(): Boolean = snapTarget <= 0
-
- /** Returns true if bottom edge was reached */
- fun bottomEdgeReached(): Boolean = snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1
-
- /** Performs snapping to the specified in [prepareSnapForItems] element */
- 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
- */
-internal fun Modifier.rotaryHandler(
- rotaryScrollHandler: RotaryScrollHandler,
- reverseDirection: Boolean,
- rotaryHaptics: RotaryHapticHandler,
- inspectorInfo: InspectorInfo.() -> Unit,
-): Modifier =
- this then
- RotaryHandlerElement(
- rotaryScrollHandler,
- reverseDirection,
- rotaryHaptics,
- inspectorInfo,
- )
-
-/**
- * Batching requests for scrolling events. This function combines all events together (except first)
- * within specified timeframe. Should help with performance on high-res devices.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-internal 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 =
- rotaryScrollDistance * delta < 0f && (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
-}
-
-private data class RotaryHandlerElement(
- private val rotaryScrollHandler: RotaryScrollHandler,
- private val reverseDirection: Boolean,
- private val rotaryHaptics: RotaryHapticHandler,
- private val inspectorInfo: InspectorInfo.() -> Unit,
-) : ModifierNodeElement<RotaryInputNode>() {
- override fun create(): RotaryInputNode =
- RotaryInputNode(
- rotaryScrollHandler,
- reverseDirection,
- rotaryHaptics,
- )
-
- override fun update(node: RotaryInputNode) {
- debugLog { "Update launched!" }
- node.rotaryScrollHandler = rotaryScrollHandler
- node.reverseDirection = reverseDirection
- node.rotaryHaptics = rotaryHaptics
- }
-
- override fun InspectorInfo.inspectableProperties() {
- inspectorInfo()
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other == null || this::class != other::class) return false
-
- other as RotaryHandlerElement
-
- if (rotaryScrollHandler != other.rotaryScrollHandler) return false
- if (reverseDirection != other.reverseDirection) return false
- if (rotaryHaptics != other.rotaryHaptics) return false
- if (inspectorInfo != other.inspectorInfo) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = rotaryScrollHandler.hashCode()
- result = 31 * result + reverseDirection.hashCode()
- result = 31 * result + rotaryHaptics.hashCode()
- result = 31 * result + inspectorInfo.hashCode()
- return result
- }
-}
-
-private class RotaryInputNode(
- var rotaryScrollHandler: RotaryScrollHandler,
- var reverseDirection: Boolean,
- var rotaryHaptics: RotaryHapticHandler,
-) : RotaryInputModifierNode, Modifier.Node() {
-
- val channel = Channel<TimestampedDelta>(capacity = Channel.CONFLATED)
- val flow = channel.receiveAsFlow()
-
- override fun onAttach() {
- coroutineScope.launch {
- flow.collectLatest {
- debugLog {
- "Scroll event received: " + "delta:${it.delta}, timestamp:${it.timestamp}"
- }
- rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics)
- }
- }
- }
-
- override fun onRotaryScrollEvent(event: RotaryScrollEvent): Boolean = false
-
- override fun onPreRotaryScrollEvent(event: RotaryScrollEvent): Boolean {
- debugLog { "onPreRotaryScrollEvent" }
- channel.trySend(
- TimestampedDelta(
- event.uptimeMillis,
- event.verticalScrollPixels * if (reverseDirection) -1f else 1f,
- ),
- )
- return true
- }
-}
-
-private fun inverseLerp(start: Float, stop: Float, value: Float): Float {
- return ((value - start) / (stop - start)).coerceIn(0f, 1f)
-}
-
-/** Debug logging that can be enabled. */
-private const val DEBUG = false
-
-private inline fun debugLog(generateMsg: () -> String) {
- if (DEBUG) {
- println("RotaryScroll: ${generateMsg()}")
- }
-}
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
deleted file mode 100644
index 1719ecef3..000000000
--- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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()
- }
-}