Add Horologist module and initial classes that are going to be needed for wear project.
Bug: 299958033
Test: N/A - project has automated tests in github
Change-Id: I6b7cea8041caa32109daeaa46ccc358452967d4a
diff --git a/packages/CredentialManager/horologist/Android.bp b/packages/CredentialManager/horologist/Android.bp
new file mode 100644
index 0000000..bb324bb
--- /dev/null
+++ b/packages/CredentialManager/horologist/Android.bp
@@ -0,0 +1,27 @@
+package {
+ // See: http://go/android-license-faq
+ // A large-scale-change added 'default_applicable_licenses' to import
+ // all of the 'license_kinds' from "frameworks_base_license"
+ // to get the below license kinds:
+ // SPDX-license-identifier-Apache-2.0
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+// TODO: ag/24733147 - Remove this project once it is imported.
+android_library {
+ name: "Horologist",
+ manifest: "AndroidManifest.xml",
+ srcs: ["src/**/*.kt"],
+ static_libs: [
+ "androidx.compose.foundation_foundation",
+ "androidx.compose.runtime_runtime",
+ "androidx.compose.ui_ui",
+ "androidx.navigation_navigation-compose",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.lifecycle_lifecycle-runtime-ktx",
+ "androidx.lifecycle_lifecycle-viewmodel-compose",
+ "androidx.wear.compose_compose-foundation",
+ "androidx.wear.compose_compose-material",
+ "androidx.wear.compose_compose-navigation",
+ ],
+}
diff --git a/packages/CredentialManager/horologist/AndroidManifest.xml b/packages/CredentialManager/horologist/AndroidManifest.xml
new file mode 100644
index 0000000..e386ce2
--- /dev/null
+++ b/packages/CredentialManager/horologist/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+ * Copyright (c) 2023 Google Inc.
+ *
+ * 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.
+ */
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.google.android.horologist">
+
+ <uses-feature android:name="android.hardware.type.watch" />
+
+</manifest>
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt
new file mode 100644
index 0000000..ae77605
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 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
+ *
+ * https://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.google.android.horologist.annotations
+
+@RequiresOptIn(
+ message = "Horologist API is experimental. The API may be changed in the future.",
+)
+@Retention(AnnotationRetention.BINARY)
+public annotation class ExperimentalHorologistApi
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt
new file mode 100644
index 0000000..c88bbd8
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:Suppress("ObjectLiteralToLambda")
+
+package com.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.AutoCenteringParams
+import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode
+
+/**
+ * Default layouts for ScalingLazyColumnState, based on UX guidance.
+ */
+public object ScalingLazyColumnDefaults {
+ /**
+ * Layout the first item, directly under the time text.
+ * This is positioned from the top of the screen instead of the
+ * center.
+ */
+ @ExperimentalHorologistApi
+ public fun belowTimeText(
+ rotaryMode: RotaryMode = RotaryMode.Scroll,
+ firstItemIsFullWidth: Boolean = false,
+ verticalArrangement: Arrangement.Vertical =
+ Arrangement.spacedBy(
+ space = 4.dp,
+ alignment = Alignment.Top,
+ ),
+ horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+ contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+ topPaddingDp: Dp = 32.dp + (if (firstItemIsFullWidth) 20.dp else 0.dp),
+ ): ScalingLazyColumnState.Factory {
+ return object : ScalingLazyColumnState.Factory {
+ @Composable
+ override fun create(): ScalingLazyColumnState {
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+
+ return remember {
+ val screenHeightPx =
+ with(density) { configuration.screenHeightDp.dp.roundToPx() }
+ val topPaddingPx = with(density) { topPaddingDp.roundToPx() }
+ val topScreenOffsetPx = screenHeightPx / 2 - topPaddingPx
+
+ ScalingLazyColumnState(
+ initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
+ index = 0,
+ offsetPx = topScreenOffsetPx,
+ ),
+ anchorType = ScalingLazyListAnchorType.ItemStart,
+ rotaryMode = rotaryMode,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ contentPadding = contentPadding,
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Layout the item [initialCenterIndex] at [initialCenterOffset] from the
+ * center of the screen.
+ */
+ @ExperimentalHorologistApi
+ public fun scalingLazyColumnDefaults(
+ rotaryMode: RotaryMode = RotaryMode.Scroll,
+ initialCenterIndex: Int = 1,
+ initialCenterOffset: Int = 0,
+ verticalArrangement: Arrangement.Vertical =
+ Arrangement.spacedBy(
+ space = 4.dp,
+ alignment = Alignment.Top,
+ ),
+ horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+ contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+ autoCentering: AutoCenteringParams? = AutoCenteringParams(
+ initialCenterIndex,
+ initialCenterOffset,
+ ),
+ anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
+ hapticsEnabled: Boolean = true,
+ reverseLayout: Boolean = false,
+ ): ScalingLazyColumnState.Factory {
+ return object : ScalingLazyColumnState.Factory {
+ @Composable
+ override fun create(): ScalingLazyColumnState {
+ return remember {
+ ScalingLazyColumnState(
+ initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
+ index = initialCenterIndex,
+ offsetPx = initialCenterOffset,
+ ),
+ rotaryMode = rotaryMode,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ contentPadding = contentPadding,
+ autoCentering = autoCentering,
+ anchorType = anchorType,
+ hapticsEnabled = hapticsEnabled,
+ reverseLayout = reverseLayout,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt
new file mode 100644
index 0000000..3a12b9f
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:Suppress("ObjectLiteralToLambda")
+@file:OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class)
+
+package com.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.lazy.AutoCenteringParams
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.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.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode
+import com.google.android.horologist.compose.rotaryinput.rememberDisabledHaptic
+import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler
+import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll
+import com.google.android.horologist.compose.rotaryinput.rotaryWithSnap
+import com.google.android.horologist.compose.rotaryinput.toRotaryScrollAdapter
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumnDefaults as WearScalingLazyColumnDefaults
+
+/**
+ * A Config and State object wrapping up all configuration for a [ScalingLazyColumn].
+ * This allows defaults such as [ScalingLazyColumnDefaults.belowTimeText].
+ */
+@ExperimentalHorologistApi
+public class ScalingLazyColumnState(
+ public val initialScrollPosition: ScrollPosition = ScrollPosition(1, 0),
+ public val autoCentering: AutoCenteringParams? = AutoCenteringParams(
+ initialScrollPosition.index,
+ initialScrollPosition.offsetPx,
+ ),
+ public val anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
+ public val contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+ public val rotaryMode: RotaryMode = RotaryMode.Scroll,
+ public val reverseLayout: Boolean = false,
+ public val verticalArrangement: Arrangement.Vertical =
+ Arrangement.spacedBy(
+ space = 4.dp,
+ alignment = if (!reverseLayout) Alignment.Top else Alignment.Bottom,
+ ),
+ public val horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+ public val flingBehavior: FlingBehavior? = null,
+ public val userScrollEnabled: Boolean = true,
+ public val scalingParams: ScalingParams = WearScalingLazyColumnDefaults.scalingParams(),
+ public val hapticsEnabled: Boolean = true,
+) {
+ private var _state: ScalingLazyListState? = null
+ public var state: ScalingLazyListState
+ get() {
+ if (_state == null) {
+ _state = ScalingLazyListState(
+ initialScrollPosition.index,
+ initialScrollPosition.offsetPx,
+ )
+ }
+ return _state!!
+ }
+ set(value) {
+ _state = value
+ }
+
+ public sealed interface RotaryMode {
+ public object Snap : RotaryMode
+ public object Scroll : RotaryMode
+
+ @Deprecated(
+ "Use RotaryMode.Scroll instead",
+ replaceWith = ReplaceWith("RotaryMode.Scroll"),
+ )
+ public object Fling : RotaryMode
+ }
+
+ public data class ScrollPosition(
+ val index: Int,
+ val offsetPx: Int,
+ )
+
+ public fun interface Factory {
+ @Composable
+ public fun create(): ScalingLazyColumnState
+ }
+}
+
+@Composable
+public fun rememberColumnState(
+ factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.belowTimeText(),
+): ScalingLazyColumnState {
+ val columnState = factory.create()
+
+ columnState.state = rememberSaveable(saver = ScalingLazyListState.Saver) {
+ columnState.state
+ }
+
+ return columnState
+}
+
+@ExperimentalHorologistApi
+@Composable
+public fun ScalingLazyColumn(
+ columnState: ScalingLazyColumnState,
+ 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,
+ )
+
+ else -> modifier.rotaryWithScroll(
+ focusRequester = focusRequester,
+ scrollableState = columnState.state,
+ reverseDirection = columnState.reverseLayout,
+ rotaryHaptics = rotaryHaptics,
+ )
+ }
+
+ ScalingLazyColumn(
+ modifier = modifierWithRotary,
+ state = columnState.state,
+ contentPadding = columnState.contentPadding,
+ reverseLayout = columnState.reverseLayout,
+ verticalArrangement = columnState.verticalArrangement,
+ horizontalAlignment = columnState.horizontalAlignment,
+ flingBehavior = columnState.flingBehavior ?: ScrollableDefaults.flingBehavior(),
+ userScrollEnabled = columnState.userScrollEnabled,
+ scalingParams = columnState.scalingParams,
+ anchorType = columnState.anchorType,
+ autoCentering = columnState.autoCentering,
+ content = content,
+ )
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt
new file mode 100644
index 0000000..623ae1a
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.scrollAway
+import com.google.android.horologist.compose.navscaffold.ScalingLazyColumnScrollableState
+
+internal fun Modifier.scrollAway(
+ scrollState: State<ScrollableState?>,
+): Modifier = composed {
+ when (val state = scrollState.value) {
+ is ScalingLazyColumnScrollableState -> {
+ val offsetDp = with(LocalDensity.current) {
+ state.initialOffsetPx.toDp()
+ }
+ this.scrollAway(state.scalingLazyListState, state.initialIndex, offsetDp)
+ }
+ is ScalingLazyListState -> this.scrollAway(state)
+ is LazyListState -> this.scrollAway(state)
+ is ScrollState -> this.scrollAway(state)
+ // Disabled
+ null -> this.hidden()
+ // Enabled but no scroll state
+ else -> this
+ }
+}
+
+internal fun Modifier.hidden(): Modifier = layout { _, _ -> layout(0, 0) {} }
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt
new file mode 100644
index 0000000..14c0ba1
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt
@@ -0,0 +1,297 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class, SavedStateHandleSaveableApi::class)
+
+package com.google.android.horologist.compose.navscaffold
+
+import android.os.Bundle
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
+import androidx.lifecycle.viewmodel.compose.saveable
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.PositionIndicator
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.TimeText
+import androidx.wear.compose.material.Vignette
+import androidx.wear.compose.material.VignettePosition
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.PositionIndicatorMode
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode.ScrollAway
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.Off
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.On
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.WhenScrollable
+
+/**
+ * A ViewModel that backs the WearNavScaffold to allow each composable to interact and effect
+ * the [Scaffold] positionIndicator, vignette and timeText.
+ *
+ * A ViewModel is used to allow the same current instance to be shared between the WearNavScaffold
+ * and the composable screen via [NavHostController.currentBackStackEntry].
+ */
+public open class NavScaffoldViewModel(
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ internal var initialIndex: Int? = null
+ internal var initialOffsetPx: Int? = null
+ internal var scrollType by mutableStateOf<ScrollType?>(null)
+
+ private lateinit var _scrollableState: ScrollableState
+
+ /**
+ * Returns the scrollable state for this composable or null if the scaffold should
+ * not consider this element to be scrollable.
+ */
+ public val scrollableState: ScrollableState?
+ get() = if (scrollType == null || scrollType == ScrollType.None) {
+ null
+ } else {
+ _scrollableState
+ }
+
+ /**
+ * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and
+ * bottom. Defaults to on for scrollable screens.
+ */
+ public var vignettePosition: VignetteMode by mutableStateOf(WhenScrollable)
+
+ /**
+ * The configuration of [TimeText], defaults to [TimeTextMode.ScrollAway] which will move the
+ * time text above the screen to avoid overlapping with the content moving up.
+ */
+ public var timeTextMode: TimeTextMode by mutableStateOf(ScrollAway)
+
+ /**
+ * The configuration of [PositionIndicator]. The default is to show a scroll bar while the
+ * scroll is in progress.
+ */
+ public var positionIndicatorMode: PositionIndicatorMode
+ by mutableStateOf(PositionIndicatorMode.On)
+
+ internal fun initializeScrollState(scrollStateBuilder: () -> ScrollState): ScrollState {
+ check(scrollType == null || scrollType == ScrollType.ScrollState)
+
+ if (scrollType == null) {
+ scrollType = ScrollType.ScrollState
+
+ _scrollableState = savedStateHandle.saveable(
+ key = "navScaffold.ScrollState",
+ saver = ScrollState.Saver,
+ ) {
+ scrollStateBuilder()
+ }
+ }
+
+ return _scrollableState as ScrollState
+ }
+
+ internal fun initializeScalingLazyListState(
+ scrollableStateBuilder: () -> ScalingLazyListState,
+ ): ScalingLazyListState {
+ check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn)
+
+ if (scrollType == null) {
+ scrollType = ScrollType.ScalingLazyColumn
+
+ _scrollableState = savedStateHandle.saveable(
+ key = "navScaffold.ScalingLazyListState",
+ saver = ScalingLazyListState.Saver,
+ ) {
+ scrollableStateBuilder().also {
+ initialIndex = it.centerItemIndex
+ initialOffsetPx = it.centerItemScrollOffset
+ }
+ }
+ }
+
+ return _scrollableState as ScalingLazyListState
+ }
+
+ internal fun initializeScalingLazyListState(
+ columnState: ScalingLazyColumnState,
+ ) {
+ check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn)
+
+ if (scrollType == null) {
+ scrollType = ScrollType.ScalingLazyColumn
+
+ initialIndex = columnState.initialScrollPosition.index
+ initialOffsetPx = columnState.initialScrollPosition.offsetPx
+
+ _scrollableState = savedStateHandle.saveable(
+ key = "navScaffold.ScalingLazyListState",
+ saver = ScalingLazyListState.Saver,
+ ) {
+ columnState.state
+ }
+ }
+
+ columnState.state = _scrollableState as ScalingLazyListState
+ }
+
+ internal fun initializeLazyList(
+ scrollableStateBuilder: () -> LazyListState,
+ ): LazyListState {
+ check(scrollType == null || scrollType == ScrollType.LazyList)
+
+ if (scrollType == null) {
+ scrollType = ScrollType.LazyList
+
+ _scrollableState = savedStateHandle.saveable(
+ key = "navScaffold.LazyListState",
+ saver = LazyListState.Saver,
+ ) {
+ scrollableStateBuilder()
+ }
+ }
+
+ return _scrollableState as LazyListState
+ }
+
+ internal enum class ScrollType {
+ None, ScalingLazyColumn, ScrollState, LazyList
+ }
+
+ /**
+ * The configuration of [TimeText], defaults to [ScrollAway] which will move the time text above the
+ * screen to avoid overlapping with the content moving up.
+ */
+ public enum class TimeTextMode {
+ On, Off, ScrollAway
+ }
+
+ /**
+ * The configuration of [PositionIndicator]. The default is to show a scroll bar while the
+ * scroll is in progress.
+ */
+ public enum class PositionIndicatorMode {
+ On, Off
+ }
+
+ /**
+ * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and
+ * bottom. Defaults to on for scrollable screens.
+ */
+ public sealed interface VignetteMode {
+ public object WhenScrollable : VignetteMode
+ public object Off : VignetteMode
+ public data class On(val position: VignettePosition) : VignetteMode
+ }
+
+ internal fun timeTextScrollableState(): ScrollableState? {
+ return when (timeTextMode) {
+ ScrollAway -> {
+ when (this.scrollType) {
+ ScrollType.ScrollState -> {
+ this.scrollableState as ScrollState
+ }
+
+ ScrollType.ScalingLazyColumn -> {
+ val scalingLazyListState =
+ this.scrollableState as ScalingLazyListState
+
+ ScalingLazyColumnScrollableState(scalingLazyListState, initialIndex
+ ?: 1, initialOffsetPx ?: 0)
+ }
+
+ ScrollType.LazyList -> {
+ this.scrollableState as LazyListState
+ }
+
+ else -> {
+ ScrollState(0)
+ }
+ }
+ }
+
+ TimeTextMode.On -> {
+ ScrollState(0)
+ }
+
+ else -> {
+ null
+ }
+ }
+ }
+}
+
+internal class ScalingLazyColumnScrollableState(
+ val scalingLazyListState: ScalingLazyListState,
+ val initialIndex: Int,
+ val initialOffsetPx: Int,
+) : ScrollableState by scalingLazyListState
+
+/**
+ * The context items provided to a navigation composable.
+ *
+ * The [viewModel] can be used to customise the scaffold behaviour.
+ */
+public data class ScaffoldContext<T : ScrollableState>(
+ val backStackEntry: NavBackStackEntry,
+ val scrollableState: T,
+ val viewModel: NavScaffoldViewModel,
+) {
+ var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+ var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+ val arguments: Bundle?
+ get() = backStackEntry.arguments
+}
+
+public data class NonScrollableScaffoldContext(
+ val backStackEntry: NavBackStackEntry,
+ val viewModel: NavScaffoldViewModel,
+) {
+ var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+ var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+ val arguments: Bundle?
+ get() = backStackEntry.arguments
+}
+
+/**
+ * The context items provided to a navigation composable.
+ *
+ * The [viewModel] can be used to customise the scaffold behaviour.
+ */
+public data class ScrollableScaffoldContext(
+ val backStackEntry: NavBackStackEntry,
+ val columnState: ScalingLazyColumnState,
+ val viewModel: NavScaffoldViewModel,
+) {
+ val scrollableState: ScalingLazyListState
+ get() = columnState.state
+
+ var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+ var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+ val arguments: Bundle?
+ get() = backStackEntry.arguments
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt
new file mode 100644
index 0000000..315d822
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt
@@ -0,0 +1,321 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:OptIn(ExperimentalWearFoundationApi::class)
+
+package com.google.android.horologist.compose.navscaffold
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.HierarchicalFocusCoordinator
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.PositionIndicator
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.TimeText
+import androidx.wear.compose.material.Vignette
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.SwipeDismissableNavHostState
+import androidx.wear.compose.navigation.composable
+import androidx.wear.compose.navigation.currentBackStackEntryAsState
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState
+import com.google.android.horologist.compose.layout.scrollAway
+
+/**
+ * A Navigation and Scroll aware [Scaffold].
+ *
+ * In addition to [NavGraphBuilder.scrollable], 3 additional extensions are supported
+ * [scalingLazyColumnComposable], [scrollStateComposable] and
+ * [lazyListComposable].
+ *
+ * These should be used to build the [ScrollableState] or [FocusRequester] as well as
+ * configure the behaviour of [TimeText], [PositionIndicator] or [Vignette].
+ */
+@Composable
+public fun WearNavScaffold(
+ startDestination: String,
+ navController: NavHostController,
+ modifier: Modifier = Modifier,
+ snackbar: @Composable () -> Unit = {},
+ timeText: @Composable (Modifier) -> Unit = {
+ TimeText(
+ modifier = it,
+ )
+ },
+ state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
+ builder: NavGraphBuilder.() -> Unit,
+) {
+ val currentBackStackEntry: NavBackStackEntry? by navController.currentBackStackEntryAsState()
+
+ val viewModel: NavScaffoldViewModel? = currentBackStackEntry?.let {
+ viewModel(viewModelStoreOwner = it)
+ }
+
+ val scrollState: State<ScrollableState?> = remember(viewModel) {
+ derivedStateOf {
+ viewModel?.timeTextScrollableState()
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ timeText = {
+ timeText(Modifier.scrollAway(scrollState))
+ },
+ positionIndicator = {
+ key(currentBackStackEntry?.destination?.route) {
+ val mode = viewModel?.positionIndicatorMode
+
+ if (mode == NavScaffoldViewModel.PositionIndicatorMode.On) {
+ NavPositionIndicator(viewModel)
+ }
+ }
+ },
+ vignette = {
+ key(currentBackStackEntry?.destination?.route) {
+ val vignettePosition = viewModel?.vignettePosition
+ if (vignettePosition is NavScaffoldViewModel.VignetteMode.On) {
+ Vignette(vignettePosition = vignettePosition.position)
+ }
+ }
+ },
+ ) {
+ Box {
+ SwipeDismissableNavHost(
+ navController = navController,
+ startDestination = startDestination,
+ state = state,
+ ) {
+ builder()
+ }
+
+ snackbar()
+ }
+ }
+}
+
+@Composable
+private fun NavPositionIndicator(viewModel: NavScaffoldViewModel) {
+ when (viewModel.scrollType) {
+ NavScaffoldViewModel.ScrollType.ScrollState ->
+ PositionIndicator(
+ scrollState = viewModel.scrollableState as ScrollState,
+ )
+
+ NavScaffoldViewModel.ScrollType.ScalingLazyColumn -> {
+ PositionIndicator(
+ scalingLazyListState = viewModel.scrollableState as ScalingLazyListState,
+ )
+ }
+
+ NavScaffoldViewModel.ScrollType.LazyList ->
+ PositionIndicator(
+ lazyListState = viewModel.scrollableState as LazyListState,
+ )
+
+ else -> {}
+ }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a ScalingLazyColumn.
+ *
+ * The scalingLazyListState must be taken from the [ScaffoldContext].
+ */
+@Deprecated(
+ "Use listComposable",
+)
+public fun NavGraphBuilder.scalingLazyColumnComposable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ scrollStateBuilder: () -> ScalingLazyListState,
+ content: @Composable (ScaffoldContext<ScalingLazyListState>) -> Unit,
+) {
+ composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val viewModel: NavScaffoldViewModel = viewModel(it)
+
+ val scrollState = viewModel.initializeScalingLazyListState(scrollStateBuilder)
+
+ content(ScaffoldContext(it, scrollState, viewModel))
+ }
+ }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a ScalingLazyColumn.
+ *
+ * The [ScalingLazyColumnState] must be taken from the [ScrollableScaffoldContext].
+ */
+@ExperimentalHorologistApi
+public fun NavGraphBuilder.scrollable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ columnStateFactory: ScalingLazyColumnState.Factory =
+ ScalingLazyColumnDefaults.belowTimeText(),
+ content: @Composable (ScrollableScaffoldContext) -> Unit,
+) {
+ this@scrollable.composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val columnState = columnStateFactory.create()
+
+ val viewModel: NavScaffoldViewModel = viewModel(it)
+
+ viewModel.initializeScalingLazyListState(columnState)
+
+ content(ScrollableScaffoldContext(it, columnState, viewModel))
+ }
+ }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a Scrollable item.
+ *
+ * The scrollState must be taken from the [ScaffoldContext].
+ */
+public fun NavGraphBuilder.scrollStateComposable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ scrollStateBuilder: () -> ScrollState = { ScrollState(0) },
+ content: @Composable (ScaffoldContext<ScrollState>) -> Unit,
+) {
+ composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val viewModel: NavScaffoldViewModel = viewModel(it)
+
+ val scrollState = viewModel.initializeScrollState(scrollStateBuilder)
+
+ content(ScaffoldContext(it, scrollState, viewModel))
+ }
+ }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
+ *
+ * The scrollState must be taken from the [ScaffoldContext].
+ */
+public fun NavGraphBuilder.lazyListComposable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ lazyListStateBuilder: () -> LazyListState = { LazyListState() },
+ content: @Composable (ScaffoldContext<LazyListState>) -> Unit,
+) {
+ composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val viewModel: NavScaffoldViewModel = viewModel(it)
+
+ val scrollState = viewModel.initializeLazyList(lazyListStateBuilder)
+
+ content(ScaffoldContext(it, scrollState, viewModel))
+ }
+ }
+}
+
+/**
+ * Add non scrolling screen to the navigation graph. The [NavBackStackEntry] and
+ * [NavScaffoldViewModel] are passed into the [content] block so that
+ * the Scaffold may be customised, such as disabling TimeText.
+ */
+@Deprecated(
+ "Use composable",
+ ReplaceWith("composable(route, arguments, deepLinks, lazyListStateBuilder, content)"),
+)
+public fun NavGraphBuilder.wearNavComposable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ content: @Composable (NavBackStackEntry, NavScaffoldViewModel) -> Unit,
+) {
+ composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val viewModel: NavScaffoldViewModel = viewModel()
+
+ content(it, viewModel)
+ }
+ }
+}
+
+/**
+ * Add non scrolling screen to the navigation graph. The [NavBackStackEntry] and
+ * [NavScaffoldViewModel] are passed into the [content] block so that
+ * the Scaffold may be customised, such as disabling TimeText.
+ */
+@ExperimentalHorologistApi
+public fun NavGraphBuilder.composable(
+ route: String,
+ arguments: List<NamedNavArgument> = emptyList(),
+ deepLinks: List<NavDeepLink> = emptyList(),
+ content: @Composable (NonScrollableScaffoldContext) -> Unit,
+) {
+ this@composable.composable(route, arguments, deepLinks) {
+ FocusedDestination {
+ val viewModel: NavScaffoldViewModel = viewModel()
+
+ content(NonScrollableScaffoldContext(it, viewModel))
+ }
+ }
+}
+
+@Composable
+internal fun FocusedDestination(content: @Composable () -> Unit) {
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ val focused =
+ remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) }
+
+ DisposableEffect(lifecycle) {
+ val listener = LifecycleEventObserver { _, _ ->
+ focused.value = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
+ }
+ lifecycle.addObserver(listener)
+ onDispose {
+ lifecycle.removeObserver(listener)
+ }
+ }
+
+ HierarchicalFocusCoordinator(requiresFocus = { focused.value }) {
+ content()
+ }
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt
new file mode 100644
index 0000000..c4af4a6
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt
@@ -0,0 +1,380 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class)
+
+package com.google.android.horologist.compose.rotaryinput
+
+import android.os.Build
+import android.view.HapticFeedbackConstants
+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 com.google.android.horologist.annotations.ExperimentalHorologistApi
+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
+import kotlin.math.abs
+
+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
+ */
+@ExperimentalHorologistApi
+public interface RotaryHapticHandler {
+
+ /**
+ * Handles haptics when scroll is used
+ */
+ @ExperimentalHorologistApi
+ public fun handleScrollHaptic(scrollDelta: Float)
+
+ /**
+ * Handles haptics when scroll with snap is used
+ */
+ @ExperimentalHorologistApi
+ public 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.
+ */
+public 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
+ */
+@ExperimentalHorologistApi
+public interface RotaryHapticFeedback {
+ @ExperimentalHorologistApi
+ public fun performHapticFeedback(type: RotaryHapticsType)
+}
+
+/**
+ * Rotary haptic types
+ */
+@ExperimentalHorologistApi
+@JvmInline
+public value class RotaryHapticsType(private val type: Int) {
+ public companion object {
+ /**
+ * A scroll ticking haptic. Similar to texture haptic - performed each time when
+ * a scrollable content is scrolled by a certain distance
+ */
+ @ExperimentalHorologistApi
+ public val ScrollTick: RotaryHapticsType = RotaryHapticsType(1)
+
+ /**
+ * An item focus (snap) haptic. Performed when a scrollable content is snapped
+ * to a specific item.
+ */
+ @ExperimentalHorologistApi
+ public val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2)
+
+ /**
+ * A limit(overscroll) haptic. Performed when a list reaches the limit
+ * (start or end) and can't scroll further
+ */
+ @ExperimentalHorologistApi
+ public val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3)
+ }
+}
+
+/**
+ * Remember disabled haptics handler
+ */
+@ExperimentalHorologistApi
+@Composable
+public 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
+ */
+@ExperimentalHorologistApi
+@Composable
+public 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,
+ )
+ }
+
+@ExperimentalHorologistApi
+@Composable
+public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback =
+ LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } }
+
+internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback =
+ if (isGooglePixelWatch()) {
+ PixelWatchRotaryHapticFeedback(view)
+ } else if (isGalaxyWatchClassic()) {
+ GalaxyWatchClassicHapticFeedback(view)
+ } else {
+ DefaultRotaryHapticFeedback(view)
+ }
+
+/**
+ * Default Rotary implementation for [RotaryHapticFeedback]
+ */
+@ExperimentalHorologistApi
+public class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+ @ExperimentalHorologistApi
+ override fun performHapticFeedback(
+ type: RotaryHapticsType,
+ ) {
+ when (type) {
+ RotaryHapticsType.ScrollItemFocus -> {
+ view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ }
+
+ RotaryHapticsType.ScrollTick -> {
+ view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
+ }
+
+ RotaryHapticsType.ScrollLimit -> {
+ view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ }
+ }
+ }
+}
+
+/**
+ * Implementation of [RotaryHapticFeedback] for Pixel Watch
+ */
+@ExperimentalHorologistApi
+private class PixelWatchRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+ @ExperimentalHorologistApi
+ override fun performHapticFeedback(
+ type: RotaryHapticsType,
+ ) {
+ when (type) {
+ RotaryHapticsType.ScrollItemFocus -> {
+ view.performHapticFeedback(
+ if (Build.VERSION.SDK_INT >= 33) {
+ ROTARY_SCROLL_ITEM_FOCUS
+ } else {
+ WEAR_SCROLL_ITEM_FOCUS
+ },
+ )
+ }
+
+ RotaryHapticsType.ScrollTick -> {
+ view.performHapticFeedback(
+ if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_TICK else WEAR_SCROLL_TICK,
+ )
+ }
+
+ RotaryHapticsType.ScrollLimit -> {
+ view.performHapticFeedback(
+ if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_LIMIT else WEAR_SCROLL_LIMIT,
+ )
+ }
+ }
+ }
+
+ private companion object {
+ // Hidden constants from HapticFeedbackConstants.java specific for Pixel Watch
+ // API 33
+ public const val ROTARY_SCROLL_TICK: Int = 18
+ public const val ROTARY_SCROLL_ITEM_FOCUS: Int = 19
+ public const val ROTARY_SCROLL_LIMIT: Int = 20
+
+ // API 30
+ public const val WEAR_SCROLL_TICK: Int = 10002
+ public const val WEAR_SCROLL_ITEM_FOCUS: Int = 10003
+ public const val WEAR_SCROLL_LIMIT: Int = 10003
+ }
+}
+
+/**
+ * Implementation of [RotaryHapticFeedback] for Galaxy Watch 4 Classic
+ */
+@ExperimentalHorologistApi
+private class GalaxyWatchClassicHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+ @ExperimentalHorologistApi
+ override fun performHapticFeedback(
+ type: RotaryHapticsType,
+ ) {
+ when (type) {
+ RotaryHapticsType.ScrollItemFocus -> {
+ // No haptic for scroll snap ( we have physical bezel)
+ }
+
+ RotaryHapticsType.ScrollTick -> {
+ // No haptic for scroll tick ( we have physical bezel)
+ }
+
+ RotaryHapticsType.ScrollLimit -> {
+ view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ }
+ }
+ }
+}
+
+private fun isGalaxyWatchClassic(): Boolean =
+ Build.MODEL.matches("SM-R8[89]5.".toRegex())
+
+private fun isGooglePixelWatch(): Boolean =
+ Build.MODEL.startsWith("Google Pixel Watch")
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt
new file mode 100644
index 0000000..3ca16c1
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt
@@ -0,0 +1,1301 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class)
+
+package com.google.android.horologist.compose.rotaryinput
+
+import android.view.ViewConfiguration
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.animateTo
+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.fastSumBy
+import androidx.compose.ui.util.lerp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+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 kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.math.sign
+
+private const val DEBUG = false
+
+/**
+ * Debug logging that can be enabled.
+ */
+private inline fun debugLog(generateMsg: () -> String) {
+ if (DEBUG) {
+ println("RotaryScroll: ${generateMsg()}")
+ }
+}
+
+/**
+ * A modifier which connects rotary events with scrollable.
+ * This modifier supports fling.
+ *
+ * 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
+ */
+@ExperimentalHorologistApi
+@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)
+@ExperimentalHorologistApi
+@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)
+@ExperimentalHorologistApi
+@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 extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState]
+ */
+@ExperimentalHorologistApi
+public fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter =
+ ScalingLazyColumnRotaryScrollAdapter(this)
+
+/**
+ * An implementation of rotary scroll adapter for [ScalingLazyColumn]
+ */
+@ExperimentalHorologistApi
+public 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
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollAdapter {
+
+ /**
+ * A scrollable state. Used for performing scroll when Rotary events received
+ */
+ @ExperimentalHorologistApi
+ public val scrollableState: ScrollableState
+
+ /**
+ * Average size of an item. Used for estimating the scrollable distance
+ */
+ @ExperimentalHorologistApi
+ public fun averageItemSize(): Float
+
+ /**
+ * A current item index. Used for scrolling
+ */
+ @ExperimentalHorologistApi
+ public fun currentItemIndex(): Int
+
+ /**
+ * An offset from the centre or the border of the current item.
+ */
+ @ExperimentalHorologistApi
+ public fun currentItemOffset(): Float
+
+ /**
+ * The total count of items in [scrollableState]
+ */
+ @ExperimentalHorologistApi
+ public fun totalItemsCount(): Int
+}
+
+/**
+ * Defaults for rotary modifiers
+ */
+@ExperimentalHorologistApi
+public 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)
+ */
+ @ExperimentalHorologistApi
+ @Composable
+ public 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
+ */
+ @ExperimentalHorologistApi
+ @Composable
+ public 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]
+ */
+ @ExperimentalHorologistApi
+ public fun snapParametersDefault(): SnapParameters =
+ SnapParameters(
+ snapOffset = 0,
+ thresholdDivider = 1.5f,
+ resistanceFactor = 3f,
+ )
+
+ /**
+ * Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb).
+ */
+ @ExperimentalHorologistApi
+ @Composable
+ public fun isLowResInput(): Boolean = LocalContext.current.packageManager
+ .hasSystemFeature("android.hardware.rotaryencoder.lowres")
+
+ private val lowResFlingTimeframe: Long = 100L
+ private val highResFlingTimeframe: Long = 30L
+}
+
+/**
+ * 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].
+ */
+public class SnapParameters(
+ public val snapOffset: Int,
+ public val thresholdDivider: Float,
+ public val resistanceFactor: Float,
+) {
+ /**
+ * Returns a snapping offset in [Dp]
+ */
+ @Composable
+ public fun snapOffsetDp(): Dp {
+ return with(LocalDensity.current) {
+ snapOffset.toDp()
+ }
+ }
+}
+
+/**
+ * An interface for handling scroll events
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollHandler {
+ /**
+ * Handles scrolling events
+ * @param coroutineScope A scope for performing async actions
+ * @param event A scrollable event from rotary input, containing scrollable delta and timestamp
+ * @param rotaryHaptics
+ */
+ @ExperimentalHorologistApi
+ public suspend fun handleScrollEvent(
+ coroutineScope: CoroutineScope,
+ event: TimestampedDelta,
+ rotaryHaptics: RotaryHapticHandler,
+ )
+}
+
+/**
+ * An interface for scrolling behavior
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollBehavior {
+ /**
+ * Handles scroll event to [targetValue]
+ */
+ @ExperimentalHorologistApi
+ public suspend fun handleEvent(targetValue: Float)
+}
+
+/**
+ * Default implementation of [RotaryFlingBehavior]
+ */
+@ExperimentalHorologistApi
+public 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
+
+ @ExperimentalHorologistApi
+ override fun startFlingTracking(timestamp: Long) {
+ rotaryVelocityTracker.start(timestamp)
+ latestEventTimestamp = timestamp
+ previousVelocity = 0f
+ }
+
+ @ExperimentalHorologistApi
+ override fun observeEvent(timestamp: Long, delta: Float) {
+ rotaryVelocityTracker.move(timestamp, delta)
+ latestEventTimestamp = timestamp
+ }
+
+ @ExperimentalHorologistApi
+ 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
+ */
+@ExperimentalHorologistApi
+public interface RotaryFlingBehavior {
+
+ /**
+ * Observing new event within a fling tracking session with new timestamp and delta
+ */
+ @ExperimentalHorologistApi
+ public fun observeEvent(timestamp: Long, delta: Float)
+
+ /**
+ * Performing fling if necessary and calling [beforeFling] lambda before it is triggered
+ */
+ @ExperimentalHorologistApi
+ public suspend fun trackFling(beforeFling: () -> Unit)
+
+ /**
+ * Starts a new fling tracking session
+ * with specified timestamp
+ */
+ @ExperimentalHorologistApi
+ public fun startFlingTracking(timestamp: Long)
+}
+
+/**
+ * An interface for snapping with rotary
+ */
+@ExperimentalHorologistApi
+public interface RotarySnapBehavior {
+
+ /**
+ * Preparing snapping. This method should be called before [snapToTargetItem] is called.
+ *
+ * Snapping is done for current + [moveForElements] items.
+ *
+ * If [sequentialSnap] is true, items are summed up together.
+ * For example, if [prepareSnapForItems] is called with
+ * [moveForElements] = 2, 3, 5 -> then the snapping will happen to current + 10 items
+ *
+ * If [sequentialSnap] is false, then [moveForElements] are not summed up together.
+ */
+ public fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean)
+
+ /**
+ * Performs snapping to the closest item.
+ */
+ public suspend fun snapToClosestItem()
+
+ /**
+ * Returns true if top edge was reached
+ */
+ public fun topEdgeReached(): Boolean
+
+ /**
+ * Returns true if bottom edge was reached
+ */
+ public fun bottomEdgeReached(): Boolean
+
+ /**
+ * Performs snapping to the specified in [prepareSnapForItems] element
+ */
+ public suspend fun snapToTargetItem()
+}
+
+/**
+ * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta].
+ */
+@ExperimentalHorologistApi
+public data class TimestampedDelta(val timestamp: Long, val delta: Float)
+
+/** 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.
+ */
+@ExperimentalHorologistApi
+public class AnimationScrollBehavior(
+ private val scrollableState: ScrollableState,
+) : RotaryScrollBehavior {
+ private var sequentialAnimation = false
+ private var scrollAnimation = AnimationState(0f)
+ private var prevPosition = 0f
+
+ @ExperimentalHorologistApi
+ override suspend fun handleEvent(targetValue: Float) {
+ 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
+ */
+@ExperimentalHorologistApi
+public 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
+
+ @ExperimentalHorologistApi
+ 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
+ */
+@ExperimentalHorologistApi
+@OptIn(ExperimentalComposeUiApi::class)
+public 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.
+ */
+@ExperimentalHorologistApi
+@OptIn(ExperimentalCoroutinesApi::class)
+public 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/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt
new file mode 100644
index 0000000..6e627c6
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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.google.android.horologist.compose.rotaryinput
+
+import androidx.compose.ui.input.pointer.util.VelocityTracker1D
+
+/**
+ * A wrapper around VelocityTracker1D to provide support for rotary input.
+ */
+public class RotaryVelocityTracker {
+ private var velocityTracker: VelocityTracker1D = VelocityTracker1D(true)
+
+ /**
+ * Retrieve the last computed velocity.
+ */
+ public val velocity: Float
+ get() = velocityTracker.calculateVelocity()
+
+ /**
+ * Start tracking motion.
+ */
+ public fun start(currentTime: Long) {
+ velocityTracker.resetTracking()
+ velocityTracker.addDataPoint(currentTime, 0f)
+ }
+
+ /**
+ * Continue tracking motion as the input rotates.
+ */
+ public fun move(currentTime: Long, delta: Float) {
+ velocityTracker.addDataPoint(currentTime, delta)
+ }
+
+ /**
+ * Stop tracking motion.
+ */
+ public fun end() {
+ velocityTracker.resetTracking()
+ }
+}