diff options
6 files changed, 68 insertions, 1663 deletions
diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt index 6ce7df125..b84f840cf 100644 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/ScrollableScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -65,7 +66,6 @@ import androidx.wear.compose.material.Vignette import androidx.wear.compose.material.VignettePosition import androidx.wear.compose.material.scrollAway import com.android.permissioncontroller.permission.ui.wear.elements.material3.WearPermissionScaffold -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithScroll import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion.MATERIAL2_5 import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme @@ -86,7 +86,7 @@ fun ScrollableScreen( isLoading: Boolean = false, titleTestTag: String? = null, subtitleTestTag: String? = null, - content: ScalingLazyListScope.() -> Unit, + content: ListScopeWrapper.() -> Unit, ) { var dismissed by remember { mutableStateOf(false) } val activity = LocalContext.current.findActivity() @@ -193,13 +193,7 @@ internal fun Wear2Scaffold( } WearPermissionTheme { Scaffold( - // TODO: Use a rotary modifier from Wear Compose once Wear Compose 1.4 is landed. - // (b/325560444) - modifier = - Modifier.rotaryWithScroll( - scrollableState = listState, - focusRequester = focusRequester, - ), + modifier = Modifier.focusRequester(focusRequester), timeText = { if (showTimeText && !isLoading) { TimeText( @@ -364,3 +358,7 @@ internal fun Context.findActivity(): Activity { } throw IllegalStateException("The screen should be called in the context of an Activity") } + +interface ListScopeWrapper { + fun item(key: Any? = null, contentType: Any? = null, content: @Composable () -> Unit) +} diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt index 0603647b1..b3698a60c 100644 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/layout/ScalingLazyColumnState.kt @@ -42,14 +42,8 @@ import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType import androidx.wear.compose.foundation.lazy.ScalingLazyListScope import androidx.wear.compose.foundation.lazy.ScalingLazyListState import androidx.wear.compose.foundation.lazy.ScalingParams -import androidx.wear.compose.foundation.rememberActiveFocusRequester import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnDefaults.responsiveScalingParams import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnState.RotaryMode -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rememberDisabledHaptic -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rememberRotaryHapticHandler -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithScroll -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.rotaryWithSnap -import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput.toRotaryScrollAdapter // This file is a copy of ScalingLazyColumnState.kt from Horologist (go/horologist), // remove it once after wear compose supports large screen dialogs. @@ -61,10 +55,7 @@ import com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput. class ScalingLazyColumnState( val initialScrollPosition: ScrollPosition = ScrollPosition(1, 0), val autoCentering: AutoCenteringParams? = - AutoCenteringParams( - initialScrollPosition.index, - initialScrollPosition.offsetPx, - ), + AutoCenteringParams(initialScrollPosition.index, initialScrollPosition.offsetPx), val anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter, val contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp), val rotaryMode: RotaryMode? = RotaryMode.Scroll, @@ -120,10 +111,7 @@ class ScalingLazyColumnState( data object Scroll : RotaryMode } - data class ScrollPosition( - val index: Int, - val offsetPx: Int, - ) + data class ScrollPosition(val index: Int, val offsetPx: Int) fun interface Factory { @Composable fun create(): ScalingLazyColumnState @@ -133,7 +121,7 @@ class ScalingLazyColumnState( // @Deprecated("Replaced by rememberResponsiveColumnState") @Composable fun rememberColumnState( - factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.responsive(), + factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.responsive() ): ScalingLazyColumnState { val columnState = factory.create() @@ -150,10 +138,7 @@ fun rememberResponsiveColumnState( last = ScalingLazyColumnDefaults.ItemType.Unspecified, ), verticalArrangement: Arrangement.Vertical = - Arrangement.spacedBy( - space = 4.dp, - alignment = Alignment.Top, - ), + Arrangement.spacedBy(space = 4.dp, alignment = Alignment.Top), rotaryMode: RotaryMode? = RotaryMode.Scroll, hapticsEnabled: Boolean = true, reverseLayout: Boolean = false, @@ -173,10 +158,7 @@ fun rememberResponsiveColumnState( val topScreenOffsetPx = screenHeightPx / 2 - topPaddingPx val initialScrollPosition = - ScalingLazyColumnState.ScrollPosition( - index = 0, - offsetPx = topScreenOffsetPx, - ) + ScalingLazyColumnState.ScrollPosition(index = 0, offsetPx = topScreenOffsetPx) val columnState = ScalingLazyColumnState( @@ -204,36 +186,8 @@ fun ScalingLazyColumn( modifier: Modifier = Modifier, content: ScalingLazyListScope.() -> Unit, ) { - val focusRequester = rememberActiveFocusRequester() - - val rotaryHaptics = - if (columnState.hapticsEnabled) { - rememberRotaryHapticHandler(columnState.state) - } else { - rememberDisabledHaptic() - } - - val modifierWithRotary = - when (columnState.rotaryMode) { - RotaryMode.Snap -> - modifier.rotaryWithSnap( - focusRequester = focusRequester, - rotaryScrollAdapter = columnState.state.toRotaryScrollAdapter(), - reverseDirection = columnState.reverseLayout, - rotaryHaptics = rotaryHaptics, - ) - RotaryMode.Scroll -> - modifier.rotaryWithScroll( - focusRequester = focusRequester, - scrollableState = columnState.state, - reverseDirection = columnState.reverseLayout, - rotaryHaptics = rotaryHaptics, - ) - else -> modifier - } - ScalingLazyColumn( - modifier = modifierWithRotary.fillMaxSize(), + modifier = modifier.fillMaxSize(), state = columnState.state, contentPadding = columnState.contentPadding, reverseLayout = columnState.reverseLayout, diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt index 9a926f5a3..babd7fb6f 100644 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt +++ b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/material3/WearPermissionScaffold.kt @@ -17,6 +17,7 @@ package com.android.permissioncontroller.permission.ui.wear.elements.material3 import android.graphics.drawable.Drawable import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues @@ -37,24 +38,41 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.ScrollInfoProvider import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.IconButtonDefaults import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.ScrollIndicator import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.TimeText +import androidx.wear.compose.material3.lazy.scrollTransform import com.android.permissioncontroller.permission.ui.wear.elements.AnnotatedText +import com.android.permissioncontroller.permission.ui.wear.elements.ListScopeWrapper import com.android.permissioncontroller.permission.ui.wear.elements.Wear2Scaffold -import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumn -import com.android.permissioncontroller.permission.ui.wear.elements.layout.ScalingLazyColumnState -import com.android.permissioncontroller.permission.ui.wear.elements.layout.rememberResponsiveColumnState import com.android.permissioncontroller.permission.ui.wear.elements.rememberDrawablePainter import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionMaterialUIVersion.MATERIAL2_5 import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme +private class TransformingScopeConverter(private val scope: TransformingLazyColumnScope) : + ListScopeWrapper { + override fun item(key: Any?, contentType: Any?, content: @Composable () -> Unit) { + scope.item { Box(modifier = Modifier.scrollTransform(this)) { content() } } + } +} + +private class ScalingScopeConverter(private val scope: ScalingLazyListScope) : ListScopeWrapper { + override fun item(key: Any?, contentType: Any?, content: @Composable () -> Unit) { + scope.item { content() } + } +} + /** * This component is wrapper on material scaffold component. It helps with time text, scroll * indicator and standard list elements like title, icon and subtitle. @@ -67,7 +85,7 @@ internal fun WearPermissionScaffold( subtitle: CharSequence?, image: Any?, isLoading: Boolean, - content: ScalingLazyListScope.() -> Unit, + content: ListScopeWrapper.() -> Unit, titleTestTag: String? = null, subtitleTestTag: String? = null, ) { @@ -79,7 +97,7 @@ internal fun WearPermissionScaffold( subtitle, image, isLoading, - content, + { content.invoke(ScalingScopeConverter(this)) }, titleTestTag, subtitleTestTag, ) @@ -90,7 +108,7 @@ internal fun WearPermissionScaffold( subtitle, image, isLoading, - content, + { content.invoke(TransformingScopeConverter(this)) }, titleTestTag, subtitleTestTag, ) @@ -104,7 +122,7 @@ private fun WearPermissionScaffoldInternal( subtitle: CharSequence?, image: Any?, isLoading: Boolean, - content: ScalingLazyListScope.() -> Unit, + content: TransformingLazyColumnScope.() -> Unit, titleTestTag: String? = null, subtitleTestTag: String? = null, ) { @@ -116,12 +134,11 @@ private fun WearPermissionScaffoldInternal( screenHeight = screenHeight, titleNeedsLargePadding = subtitle == null, ) - val columnState = - rememberResponsiveColumnState(contentPadding = { paddingDefaults.scrollContentPadding }) + val columnState = rememberTransformingLazyColumnState() WearPermissionTheme(version = WearPermissionMaterialUIVersion.MATERIAL3) { AppScaffold(timeText = wearPermissionTimeText(showTimeText && !isLoading)) { ScreenScaffold( - scrollInfoProvider = ScrollInfoProvider(columnState.state), + scrollInfoProvider = ScrollInfoProvider(columnState), scrollIndicator = wearPermissionScrollIndicator(!isLoading, columnState), ) { Box(modifier = Modifier.fillMaxSize()) { @@ -129,6 +146,7 @@ private fun WearPermissionScaffoldInternal( CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } else { ScrollingView( + contentPadding = paddingDefaults.scrollContentPadding, columnState = columnState, icon = painterFromImage(image), title = title, @@ -184,7 +202,8 @@ private class WearPermissionScaffoldPaddingDefaults( @Composable private fun BoxScope.ScrollingView( - columnState: ScalingLazyColumnState, + contentPadding: PaddingValues, + columnState: TransformingLazyColumnState, icon: Painter?, title: String?, titleTestTag: String?, @@ -192,16 +211,26 @@ private fun BoxScope.ScrollingView( subtitleTestTag: String?, titlePaddingValues: PaddingValues, subTitlePaddingValues: PaddingValues, - content: ScalingLazyListScope.() -> Unit, + content: TransformingLazyColumnScope.() -> Unit, ) { - ScalingLazyColumn(columnState = columnState) { - iconItem(icon, Modifier.size(24.dp)) - titleItem(text = title, testTag = titleTestTag, contentPaddingValues = titlePaddingValues) - subtitleItem( - text = subtitle, - testTag = subtitleTestTag, - modifier = Modifier.align(Alignment.Center).padding(subTitlePaddingValues), - ) + TransformingLazyColumn( + contentPadding = contentPadding, + state = columnState, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) { + with(TransformingScopeConverter(this)) { + iconItem(icon, Modifier.size(IconButtonDefaults.LargeIconSize)) + titleItem( + text = title, + testTag = titleTestTag, + contentPaddingValues = titlePaddingValues, + ) + subtitleItem( + text = subtitle, + testTag = subtitleTestTag, + modifier = Modifier.align(Alignment.Center).padding(subTitlePaddingValues), + ) + } content() } } @@ -216,15 +245,10 @@ private fun wearPermissionTimeText(showTime: Boolean): @Composable () -> Unit { private fun wearPermissionScrollIndicator( showIndicator: Boolean, - columnState: ScalingLazyColumnState, + columnState: TransformingLazyColumnState, ): @Composable (BoxScope.() -> Unit)? { return if (showIndicator) { - { - ScrollIndicator( - modifier = Modifier.align(Alignment.CenterEnd), - state = columnState.state, - ) - } + { ScrollIndicator(modifier = Modifier.align(Alignment.CenterEnd), state = columnState) } } else { null } @@ -246,7 +270,7 @@ private fun Modifier.optionalTestTag(tag: String?): Modifier { return this then testTag(tag) } -private fun ScalingLazyListScope.iconItem(painter: Painter?, modifier: Modifier = Modifier) = +private fun ListScopeWrapper.iconItem(painter: Painter?, modifier: Modifier = Modifier) = painter?.let { item { val iconColor = WearPermissionButtonStyle.Secondary.material3ButtonColors().iconColor @@ -260,14 +284,14 @@ private fun ScalingLazyListScope.iconItem(painter: Painter?, modifier: Modifier } } -private fun ScalingLazyListScope.titleItem( +private fun ListScopeWrapper.titleItem( text: String?, testTag: String?, contentPaddingValues: PaddingValues, modifier: Modifier = Modifier, ) = text?.let { - item { + item(contentType = "header") { ListHeader( modifier = modifier.requiredHeightIn(1.dp), // We do not want default min height contentPadding = contentPaddingValues, @@ -281,7 +305,7 @@ private fun ScalingLazyListScope.titleItem( } } -private fun ScalingLazyListScope.subtitleItem( +private fun ListScopeWrapper.subtitleItem( text: CharSequence?, testTag: String?, modifier: Modifier = Modifier, diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt deleted file mode 100644 index 817bf7efe..000000000 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Haptics.kt +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput - -import android.os.Build -import android.view.View -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalView -import kotlin.math.abs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.withContext - -// This file is a copy of Haptics.kt from Horologist (go/horologist), -// remove it once Wear Compose 1.4 is landed (b/325560444). - -private const val DEBUG = false - -/** Debug logging that can be enabled. */ -private inline fun debugLog(generateMsg: () -> String) { - if (DEBUG) { - println("RotaryHaptics: ${generateMsg()}") - } -} - -/** - * Throttling events within specified timeframe. Only first and last events will be received. For a - * flow emitting elements 1 to 30, with a 100ms delay between them: - * ``` - * val flow = flow { - * for (i in 1..30) { - * delay(100) - * emit(i) - * } - * } - * ``` - * - * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 . - */ -internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> = flow { - conflate().collect { - emit(it) - delay(timeframe) - } -} - -/** Handles haptics for rotary usage */ -interface RotaryHapticHandler { - - /** Handles haptics when scroll is used */ - fun handleScrollHaptic(scrollDelta: Float) - - /** Handles haptics when scroll with snap is used */ - fun handleSnapHaptic(scrollDelta: Float) -} - -/** - * Default implementation of [RotaryHapticHandler]. It handles haptic feedback based on the - * [scrollableState], scrolled pixels and [hapticsThresholdPx]. Haptic is not fired in this class, - * instead it's sent to [hapticsChannel] where it'll performed later. - * - * @param scrollableState Haptic performed based on this state - * @param hapticsChannel Channel to which haptic events will be sent - * @param hapticsThresholdPx A scroll threshold after which haptic is produced. - */ -class DefaultRotaryHapticHandler( - private val scrollableState: ScrollableState, - private val hapticsChannel: Channel<RotaryHapticsType>, - private val hapticsThresholdPx: Long = 50, -) : RotaryHapticHandler { - - private var overscrollHapticTriggered = false - private var currScrollPosition = 0f - private var prevHapticsPosition = 0f - - override fun handleScrollHaptic(scrollDelta: Float) { - if ( - (scrollDelta > 0 && !scrollableState.canScrollForward) || - (scrollDelta < 0 && !scrollableState.canScrollBackward) - ) { - if (!overscrollHapticTriggered) { - trySendHaptic(RotaryHapticsType.ScrollLimit) - overscrollHapticTriggered = true - } - } else { - overscrollHapticTriggered = false - currScrollPosition += scrollDelta - val diff = abs(currScrollPosition - prevHapticsPosition) - - if (diff >= hapticsThresholdPx) { - trySendHaptic(RotaryHapticsType.ScrollTick) - prevHapticsPosition = currScrollPosition - } - } - } - - override fun handleSnapHaptic(scrollDelta: Float) { - if ( - (scrollDelta > 0 && !scrollableState.canScrollForward) || - (scrollDelta < 0 && !scrollableState.canScrollBackward) - ) { - if (!overscrollHapticTriggered) { - trySendHaptic(RotaryHapticsType.ScrollLimit) - overscrollHapticTriggered = true - } - } else { - overscrollHapticTriggered = false - trySendHaptic(RotaryHapticsType.ScrollItemFocus) - } - } - - private fun trySendHaptic(rotaryHapticsType: RotaryHapticsType) { - // Ok to ignore the ChannelResult because we default to capacity = 2 and DROP_OLDEST - @Suppress("UNUSED_VARIABLE") val unused = hapticsChannel.trySend(rotaryHapticsType) - } -} - -/** Interface for Rotary haptic feedback */ -interface RotaryHapticFeedback { - fun performHapticFeedback(type: RotaryHapticsType) -} - -/** Rotary haptic types */ -@JvmInline -value class RotaryHapticsType(private val type: Int) { - companion object { - /** - * A scroll ticking haptic. Similar to texture haptic - performed each time when a - * scrollable content is scrolled by a certain distance - */ - val ScrollTick: RotaryHapticsType = RotaryHapticsType(1) - - /** - * An item focus (snap) haptic. Performed when a scrollable content is snapped to a specific - * item. - */ - val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2) - - /** - * A limit(overscroll) haptic. Performed when a list reaches the limit (start or end) and - * can't scroll further - */ - val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3) - } -} - -/** Remember disabled haptics handler */ -@Composable -fun rememberDisabledHaptic(): RotaryHapticHandler = remember { - object : RotaryHapticHandler { - - override fun handleScrollHaptic(scrollDelta: Float) { - // Do nothing - } - - override fun handleSnapHaptic(scrollDelta: Float) { - // Do nothing - } - } -} - -/** - * Remember rotary haptic handler. - * - * @param scrollableState A scrollableState, used to determine whether the end of the scrollable was - * reached or not. - * @param throttleThresholdMs Throttling events within specified timeframe. Only first and last - * events will be received. Check [throttleLatest] for more info. - * @param hapticsThresholdPx A scroll threshold after which haptic is produced. - * @param hapticsChannel Channel to which haptic events will be sent - * @param rotaryHaptics Interface for Rotary haptic feedback which performs haptics - */ -@Composable -fun rememberRotaryHapticHandler( - scrollableState: ScrollableState, - throttleThresholdMs: Long = 30, - hapticsThresholdPx: Long = 50, - hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel(), - rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback(), -): RotaryHapticHandler { - return remember(scrollableState, hapticsChannel, rotaryHaptics) { - DefaultRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx) - } - .apply { - LaunchedEffect(hapticsChannel) { - hapticsChannel.receiveAsFlow().throttleLatest(throttleThresholdMs).collect { - hapticType -> - // 'withContext' launches performHapticFeedback in a separate thread, - // as otherwise it produces a visible lag (b/219776664) - val currentTime = System.currentTimeMillis() - debugLog { "Haptics started" } - withContext(Dispatchers.Default) { - debugLog { - "Performing haptics, delay: " + - "${System.currentTimeMillis() - currentTime}" - } - rotaryHaptics.performHapticFeedback(hapticType) - } - } - } - } -} - -@Composable -private fun rememberHapticChannel() = remember { - Channel<RotaryHapticsType>( - capacity = 2, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) -} - -@Composable -public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback = - LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } } - -internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback = - if (isSamsungWatch()) { - SamsungWatchHapticFeedback(view) - } else { - DefaultRotaryHapticFeedback(view) - } - -/** Default Rotary implementation for [RotaryHapticFeedback] */ -class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback { - - override fun performHapticFeedback( - type: RotaryHapticsType, - ) { - when (type) { - RotaryHapticsType.ScrollItemFocus -> { - view.performHapticFeedback(SCROLL_ITEM_FOCUS) - } - RotaryHapticsType.ScrollTick -> { - view.performHapticFeedback(SCROLL_TICK) - } - RotaryHapticsType.ScrollLimit -> { - view.performHapticFeedback(SCROLL_LIMIT) - } - } - } - - private companion object { - // Hidden constants from HapticFeedbackConstants - const val SCROLL_TICK: Int = 18 - const val SCROLL_ITEM_FOCUS: Int = 19 - const val SCROLL_LIMIT: Int = 20 - } -} - -/** Implementation of [RotaryHapticFeedback] for Samsung devices */ -private class SamsungWatchHapticFeedback(private val view: View) : RotaryHapticFeedback { - override fun performHapticFeedback( - type: RotaryHapticsType, - ) { - when (type) { - RotaryHapticsType.ScrollItemFocus -> { - view.performHapticFeedback(102) - } - RotaryHapticsType.ScrollTick -> { - view.performHapticFeedback(102) - } - RotaryHapticsType.ScrollLimit -> { - view.performHapticFeedback(50107) - } - } - } -} - -private fun isSamsungWatch(): Boolean = Build.MANUFACTURER.contains("Samsung", ignoreCase = true) diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt deleted file mode 100644 index 19a6ea671..000000000 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/Rotary.kt +++ /dev/null @@ -1,1232 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput - -import android.view.ViewConfiguration -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.Easing -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.copy -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.rotary.RotaryInputModifierNode -import androidx.compose.ui.input.rotary.RotaryScrollEvent -import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.util.fastSumBy -import androidx.compose.ui.util.lerp -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.lazy.ScalingLazyListState -import androidx.wear.compose.foundation.rememberActiveFocusRequester -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.math.sign -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch - -// This file is a copy of Rotary.kt from Horologist (go/horologist), -// remove it once Wear Compose 1.4 is landed (b/325560444). - -/** - * A modifier which connects rotary events with scrollable. This modifier supports scroll with - * fling. - * - * @param scrollableState Scrollable state which will be scrolled while receiving rotary events - * @param focusRequester Requests the focus for rotary input. By default comes from - * [rememberActiveFocusRequester], which is used with [HierarchicalFocusCoordinator] - * @param flingBehavior Logic describing fling behavior. If null fling will not happen. - * @param rotaryHaptics Class which will handle haptic feedback - * @param reverseDirection Reverse the direction of scrolling. Should be aligned with Scrollable - * `reverseDirection` parameter - */ -@OptIn(ExperimentalWearFoundationApi::class) -@Suppress("ComposableModifierFactory") -@Composable -fun Modifier.rotaryWithScroll( - scrollableState: ScrollableState, - focusRequester: FocusRequester = rememberActiveFocusRequester(), - flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(), - rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState), - reverseDirection: Boolean = false, -): Modifier = - rotaryHandler( - rotaryScrollHandler = - RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior), - reverseDirection = reverseDirection, - rotaryHaptics = rotaryHaptics, - inspectorInfo = - debugInspectorInfo { - name = "rotaryWithFling" - properties["scrollableState"] = scrollableState - properties["focusRequester"] = focusRequester - properties["flingBehavior"] = flingBehavior - properties["rotaryHaptics"] = rotaryHaptics - properties["reverseDirection"] = reverseDirection - }, - ) - .focusRequester(focusRequester) - .focusable() - -/** - * A modifier which connects rotary events with scrollable. This modifier supports snap. - * - * @param focusRequester Requests the focus for rotary input. By default comes from - * [rememberActiveFocusRequester], which is used with [HierarchicalFocusCoordinator] - * @param rotaryScrollAdapter A connection between scrollable objects and rotary events - * @param rotaryHaptics Class which will handle haptic feedback - * @param reverseDirection Reverse the direction of scrolling. Should be aligned with Scrollable - * `reverseDirection` parameter - */ -@OptIn(ExperimentalWearFoundationApi::class) -@Suppress("ComposableModifierFactory") -@Composable -fun Modifier.rotaryWithSnap( - rotaryScrollAdapter: RotaryScrollAdapter, - focusRequester: FocusRequester = rememberActiveFocusRequester(), - snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault, - rotaryHaptics: RotaryHapticHandler = - rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState), - reverseDirection: Boolean = false, -): Modifier = - rotaryHandler( - rotaryScrollHandler = - RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters), - reverseDirection = reverseDirection, - rotaryHaptics = rotaryHaptics, - inspectorInfo = - debugInspectorInfo { - name = "rotaryWithFling" - properties["rotaryScrollAdapter"] = rotaryScrollAdapter - properties["focusRequester"] = focusRequester - properties["snapParameters"] = snapParameters - properties["rotaryHaptics"] = rotaryHaptics - properties["reverseDirection"] = reverseDirection - }, - ) - .focusRequester(focusRequester) - .focusable() - -/** An extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState] */ -@Composable -fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter = - remember(this) { ScalingLazyColumnRotaryScrollAdapter(this) } - -/** An implementation of rotary scroll adapter for [ScalingLazyColumn] */ -class ScalingLazyColumnRotaryScrollAdapter( - override val scrollableState: ScalingLazyListState, -) : RotaryScrollAdapter { - - /** Calculates an average height of an item by taking an average from visible items height. */ - override fun averageItemSize(): Float { - val visibleItems = scrollableState.layoutInfo.visibleItemsInfo - return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat() - } - - /** Current (centred) item index */ - override fun currentItemIndex(): Int = scrollableState.centerItemIndex - - /** An offset from the item centre */ - override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat() - - /** The total count of items in ScalingLazyColumn */ - override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount -} - -/** An adapter which connects scrollableState to Rotary */ -interface RotaryScrollAdapter { - - /** A scrollable state. Used for performing scroll when Rotary events received */ - val scrollableState: ScrollableState - - /** Average size of an item. Used for estimating the scrollable distance */ - fun averageItemSize(): Float - - /** A current item index. Used for scrolling */ - fun currentItemIndex(): Int - - /** An offset from the centre or the border of the current item. */ - fun currentItemOffset(): Float - - /** The total count of items in [scrollableState] */ - fun totalItemsCount(): Int -} - -/** Defaults for rotary modifiers */ -object RotaryDefaults { - - /** Returns default [SnapParameters] */ - val snapParametersDefault: SnapParameters = - SnapParameters( - snapOffset = 0, - thresholdDivider = 1.5f, - resistanceFactor = 3f, - ) - - /** Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb). */ - @Composable - fun isLowResInput(): Boolean = - LocalContext.current.packageManager.hasSystemFeature( - "android.hardware.rotaryencoder.lowres" - ) - - /** - * Handles scroll with fling. - * - * @param scrollableState Scrollable state which will be scrolled while receiving rotary events - * @param flingBehavior Logic describing Fling behavior. If null - fling will not happen - * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb) - */ - @Composable - internal fun rememberFlingHandler( - scrollableState: ScrollableState, - flingBehavior: FlingBehavior? = null, - isLowRes: Boolean = isLowResInput(), - ): RotaryScrollHandler { - val viewConfiguration = ViewConfiguration.get(LocalContext.current) - - return remember(scrollableState, flingBehavior, isLowRes) { - // Remove unnecessary recompositions by disabling tracking of changes inside of - // this block. This algorithm properly reads all updated values and - // don't need recomposition when those values change. - Snapshot.withoutReadObservation { - debugLog { "isLowRes : $isLowRes" } - fun rotaryFlingBehavior() = - flingBehavior?.run { - RotaryFlingBehavior( - scrollableState, - flingBehavior, - viewConfiguration, - flingTimeframe = - if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe, - ) - } - - fun scrollBehavior() = RotaryScrollBehavior(scrollableState) - - if (isLowRes) { - LowResRotaryScrollHandler( - rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, - scrollBehaviorFactory = { scrollBehavior() }, - ) - } else { - HighResRotaryScrollHandler( - rotaryFlingBehaviorFactory = { rotaryFlingBehavior() }, - scrollBehaviorFactory = { scrollBehavior() }, - ) - } - } - } - } - - /** - * Handles scroll with snap - * - * @param rotaryScrollAdapter A connection between scrollable objects and rotary events - * @param snapParameters Snap parameters - */ - @Composable - internal fun rememberSnapHandler( - rotaryScrollAdapter: RotaryScrollAdapter, - snapParameters: SnapParameters = snapParametersDefault, - isLowRes: Boolean = isLowResInput(), - ): RotaryScrollHandler { - return remember(rotaryScrollAdapter, snapParameters) { - // Remove unnecessary recompositions by disabling tracking of changes inside of - // this block. This algorithm properly reads all updated values and - // don't need recomposition when those values change. - Snapshot.withoutReadObservation { - debugLog { "isLowRes : $isLowRes" } - if (isLowRes) { - LowResSnapHandler( - snapBehaviourFactory = { - RotarySnapBehavior(rotaryScrollAdapter, snapParameters) - }, - ) - } else { - HighResSnapHandler( - resistanceFactor = snapParameters.resistanceFactor, - thresholdBehaviorFactory = { - ThresholdBehavior( - rotaryScrollAdapter, - snapParameters.thresholdDivider, - ) - }, - snapBehaviourFactory = { - RotarySnapBehavior(rotaryScrollAdapter, snapParameters) - }, - scrollBehaviourFactory = { - RotaryScrollBehavior(rotaryScrollAdapter.scrollableState) - }, - ) - } - } - } - } - - private val lowResFlingTimeframe: Long = 100L - private val highResFlingTimeframe: Long = 30L -} - -/** - * Parameters used for snapping - * - * @param snapOffset an optional offset to be applied when snapping the item. After the snap the - * snapped items offset will be [snapOffset]. - */ -class SnapParameters( - val snapOffset: Int, - val thresholdDivider: Float, - val resistanceFactor: Float, -) { - /** Returns a snapping offset in [Dp] */ - @Composable - fun snapOffsetDp(): Dp { - return with(LocalDensity.current) { snapOffset.toDp() } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as SnapParameters - - if (snapOffset != other.snapOffset) return false - if (thresholdDivider != other.thresholdDivider) return false - if (resistanceFactor != other.resistanceFactor) return false - - return true - } - - override fun hashCode(): Int { - var result = snapOffset - result = 31 * result + thresholdDivider.hashCode() - result = 31 * result + resistanceFactor.hashCode() - return result - } -} - -/** An interface for handling scroll events */ -internal interface RotaryScrollHandler { - /** - * Handles scrolling events - * - * @param coroutineScope A scope for performing async actions - * @param event A scrollable event from rotary input, containing scrollable delta and timestamp - * @param rotaryHaptics - */ - suspend fun handleScrollEvent( - coroutineScope: CoroutineScope, - event: TimestampedDelta, - rotaryHaptics: RotaryHapticHandler, - ) -} - -/** - * Class responsible for Fling behaviour with rotary. It tracks and produces the fling when - * necessary - */ -internal class RotaryFlingBehavior( - private val scrollableState: ScrollableState, - private val flingBehavior: FlingBehavior, - viewConfiguration: ViewConfiguration, - private val flingTimeframe: Long, -) { - - // A time range during which the fling is valid. - // For simplicity it's twice as long as [flingTimeframe] - private val timeRangeToFling = flingTimeframe * 2 - - // A default fling factor for making fling slower - private val flingScaleFactor = 0.7f - - private var previousVelocity = 0f - - private val rotaryVelocityTracker = RotaryVelocityTracker() - - private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat() - private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat() - private var latestEventTimestamp: Long = 0 - - private var flingVelocity: Float = 0f - private var flingTimestamp: Long = 0 - - /** Starts a new fling tracking session with specified timestamp */ - fun startFlingTracking(timestamp: Long) { - rotaryVelocityTracker.start(timestamp) - latestEventTimestamp = timestamp - previousVelocity = 0f - } - - /** Observing new event within a fling tracking session with new timestamp and delta */ - fun observeEvent(timestamp: Long, delta: Float) { - rotaryVelocityTracker.move(timestamp, delta) - latestEventTimestamp = timestamp - } - - /** Performing fling if necessary and calling [beforeFling] lambda before it is triggered */ - suspend fun trackFling(beforeFling: () -> Unit) { - val currentVelocity = rotaryVelocityTracker.velocity - debugLog { "currentVelocity: $currentVelocity" } - - if (abs(currentVelocity) >= abs(previousVelocity)) { - flingTimestamp = latestEventTimestamp - flingVelocity = currentVelocity * flingScaleFactor - } - previousVelocity = currentVelocity - - // Waiting for a fixed amount of time before checking the fling - delay(flingTimeframe) - - // For making a fling 2 criteria should be met: - // 1) no more than - // `rangeToFling` ms should pass between last fling detection - // and the time of last motion event - // 2) flingVelocity should exceed the minFlingSpeed - debugLog { - "Check fling: flingVelocity: $flingVelocity " + - "minFlingSpeed: $minFlingSpeed, maxFlingSpeed: $maxFlingSpeed" - } - if ( - latestEventTimestamp - flingTimestamp < timeRangeToFling && - abs(flingVelocity) > minFlingSpeed - ) { - // Stops scrollAnimationCoroutine because a fling will be performed - beforeFling() - val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed) - scrollableState.scroll(MutatePriority.UserInput) { - with(flingBehavior) { - debugLog { "Flinging with velocity $velocity" } - performFling(velocity) - } - } - } - } -} - -/** - * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta]. - */ -internal data class TimestampedDelta(val timestamp: Long, val delta: Float) - -/** - * This class does a smooth animation when the scroll by N pixels is done. This animation works well - * on Rsb(high-res) and Bezel(low-res) devices. - */ -internal class RotaryScrollBehavior( - private val scrollableState: ScrollableState, -) { - private var sequentialAnimation = false - private var scrollAnimation = AnimationState(0f) - private var prevPosition = 0f - - /** Handles scroll event to [targetValue] */ - suspend fun handleEvent(targetValue: Float) { - scrollableState.scroll(MutatePriority.UserInput) { - debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" } - - scrollAnimation.animateTo( - targetValue, - animationSpec = spring(), - sequentialAnimation = sequentialAnimation, - ) { - val delta = value - prevPosition - debugLog { "Animated by $delta, value: $value" } - scrollBy(delta) - prevPosition = value - sequentialAnimation = value != this.targetValue - } - } - } -} - -/** - * A helper class for snapping with rotary. Uses animateScrollToItem method for snapping to the Nth - * item. - */ -internal class RotarySnapBehavior( - private val rotaryScrollAdapter: RotaryScrollAdapter, - private val snapParameters: SnapParameters, -) { - private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex() - private var sequentialSnap: Boolean = false - - private var anim = AnimationState(0f) - private var expectedDistance = 0f - - private val defaultStiffness = 200f - private var snapTargetUpdated = true - - /** - * Preparing snapping. This method should be called before [snapToTargetItem] is called. - * - * Snapping is done for current + [moveForElements] items. - * - * If [sequentialSnap] is true, items are summed up together. For example, if - * [prepareSnapForItems] is called with [moveForElements] = 2, 3, 5 -> then the snapping will - * happen to current + 10 items - * - * If [sequentialSnap] is false, then [moveForElements] are not summed up together. - */ - fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) { - this.sequentialSnap = sequentialSnap - if (sequentialSnap) { - snapTarget += moveForElements - } else { - snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements - } - snapTargetUpdated = true - snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount()) - } - - /** Performs snapping to the closest item. */ - suspend fun snapToClosestItem() { - // Snapping to the closest item by using performFling method with 0 speed - rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) { - debugLog { "snap to closest item" } - var prevPosition = 0f - AnimationState(0f).animateTo( - targetValue = -rotaryScrollAdapter.currentItemOffset(), - animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing), - ) { - val animDelta = value - prevPosition - scrollBy(animDelta) - prevPosition = value - } - snapTarget = rotaryScrollAdapter.currentItemIndex() - } - } - - /** Returns true if top edge was reached */ - fun topEdgeReached(): Boolean = snapTarget <= 0 - - /** Returns true if bottom edge was reached */ - fun bottomEdgeReached(): Boolean = snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1 - - /** Performs snapping to the specified in [prepareSnapForItems] element */ - suspend fun snapToTargetItem() { - if (sequentialSnap) { - anim = anim.copy(0f) - } else { - anim = AnimationState(0f) - } - rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) { - // If snapTargetUpdated is true - then the target was updated so we - // need to do snap again - while (snapTargetUpdated) { - snapTargetUpdated = false - var latestCenterItem: Int - var continueFirstScroll = true - debugLog { "snapTarget $snapTarget" } - while (continueFirstScroll) { - latestCenterItem = rotaryScrollAdapter.currentItemIndex() - anim = anim.copy(0f) - expectedDistance = expectedDistanceTo(snapTarget, snapParameters.snapOffset) - debugLog { - "expectedDistance = $expectedDistance, " + - "scrollableState.centerItemScrollOffset " + - "${rotaryScrollAdapter.currentItemOffset()}" - } - continueFirstScroll = false - var prevPosition = 0f - - anim.animateTo( - expectedDistance, - animationSpec = - SpringSpec( - stiffness = defaultStiffness, - visibilityThreshold = 0.1f, - ), - sequentialAnimation = (anim.velocity != 0f), - ) { - val animDelta = value - prevPosition - debugLog { - "First animation, value:$value, velocity:$velocity, " + - "animDelta:$animDelta" - } - - // Exit animation if snap target was updated - if (snapTargetUpdated) cancelAnimation() - - scrollBy(animDelta) - prevPosition = value - - if (latestCenterItem != rotaryScrollAdapter.currentItemIndex()) { - continueFirstScroll = true - cancelAnimation() - return@animateTo - } - - debugLog { "centerItemIndex = ${rotaryScrollAdapter.currentItemIndex()}" } - if (rotaryScrollAdapter.currentItemIndex() == snapTarget) { - debugLog { "Target is visible. Cancelling first animation" } - debugLog { - "scrollableState.centerItemScrollOffset " + - "${rotaryScrollAdapter.currentItemOffset()}" - } - expectedDistance = -rotaryScrollAdapter.currentItemOffset() - continueFirstScroll = false - cancelAnimation() - return@animateTo - } - } - } - // Exit animation if snap target was updated - if (snapTargetUpdated) continue - - anim = anim.copy(0f) - var prevPosition = 0f - anim.animateTo( - expectedDistance, - animationSpec = - SpringSpec( - stiffness = defaultStiffness, - visibilityThreshold = 0.1f, - ), - sequentialAnimation = (anim.velocity != 0f), - ) { - // Exit animation if snap target was updated - if (snapTargetUpdated) cancelAnimation() - - val animDelta = value - prevPosition - debugLog { "Final animation. velocity:$velocity, animDelta:$animDelta" } - scrollBy(animDelta) - prevPosition = value - } - } - } - } - - private fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float { - val averageSize = rotaryScrollAdapter.averageItemSize() - val indexesDiff = index - rotaryScrollAdapter.currentItemIndex() - debugLog { "Average size $averageSize" } - return (averageSize * indexesDiff) + targetScrollOffset - - rotaryScrollAdapter.currentItemOffset() - } -} - -/** - * A modifier which handles rotary events. It accepts ScrollHandler as the input - a class where - * main logic about how scroll should be handled is lying - */ -internal fun Modifier.rotaryHandler( - rotaryScrollHandler: RotaryScrollHandler, - reverseDirection: Boolean, - rotaryHaptics: RotaryHapticHandler, - inspectorInfo: InspectorInfo.() -> Unit, -): Modifier = - this then - RotaryHandlerElement( - rotaryScrollHandler, - reverseDirection, - rotaryHaptics, - inspectorInfo, - ) - -/** - * Batching requests for scrolling events. This function combines all events together (except first) - * within specified timeframe. Should help with performance on high-res devices. - */ -@OptIn(ExperimentalCoroutinesApi::class) -internal fun Flow<TimestampedDelta>.batchRequestsWithinTimeframe( - timeframe: Long -): Flow<TimestampedDelta> { - var delta = 0f - var lastTimestamp = -timeframe - return if (timeframe == 0L) { - this - } else { - this.transformLatest { - delta += it.delta - debugLog { "Batching requests. delta:$delta" } - if (lastTimestamp + timeframe <= it.timestamp) { - lastTimestamp = it.timestamp - debugLog { "No events before, delta= $delta" } - emit(TimestampedDelta(it.timestamp, delta)) - } else { - delay(timeframe) - debugLog { "After delay, delta= $delta" } - if (delta > 0f) { - emit(TimestampedDelta(it.timestamp, delta)) - } - } - delta = 0f - } - } -} - -/** - * A scroll handler for RSB(high-res) without snapping and with or without fling A list is scrolled - * by the number of pixels received from the rotary device. - * - * This class is a little bit different from LowResScrollHandler class - it has a filtering for - * events which are coming with wrong sign ( this happens to rsb devices, especially at the end of - * the scroll) - * - * This scroll handler supports fling. It can be set with [RotaryFlingBehavior]. - */ -internal class HighResRotaryScrollHandler( - private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?, - private val scrollBehaviorFactory: () -> RotaryScrollBehavior, - private val hapticsThreshold: Long = 50, -) : RotaryScrollHandler { - - // This constant is specific for high-res devices. Because that input values - // can sometimes come with different sign, we have to filter them in this threshold - private val gestureThresholdTime = 200L - private var scrollJob: Job = CompletableDeferred<Unit>() - private var flingJob: Job = CompletableDeferred<Unit>() - - private var previousScrollEventTime = 0L - private var rotaryScrollDistance = 0f - - private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory() - private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory() - - override suspend fun handleScrollEvent( - coroutineScope: CoroutineScope, - event: TimestampedDelta, - rotaryHaptics: RotaryHapticHandler, - ) { - val time = event.timestamp - val isOppositeScrollValue = isOppositeValueAfterScroll(event.delta) - - if (isNewScrollEvent(time)) { - debugLog { "New scroll event" } - resetTracking(time) - rotaryScrollDistance = event.delta - } else { - // Due to the physics of Rotary side button, some events might come - // with an opposite axis value - either at the start or at the end of the motion. - // We don't want to use these values for fling calculations. - if (!isOppositeScrollValue) { - rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta) - } else { - debugLog { "Opposite value after scroll :${event.delta}" } - } - rotaryScrollDistance += event.delta - } - - scrollJob.cancel() - - rotaryHaptics.handleScrollHaptic(event.delta) - debugLog { "Rotary scroll distance: $rotaryScrollDistance" } - - previousScrollEventTime = time - scrollJob = coroutineScope.async { scrollBehavior.handleEvent(rotaryScrollDistance) } - - if (rotaryFlingBehavior != null) { - flingJob.cancel() - flingJob = - coroutineScope.async { - rotaryFlingBehavior?.trackFling( - beforeFling = { - debugLog { "Calling before fling section" } - scrollJob.cancel() - scrollBehavior = scrollBehaviorFactory() - } - ) - } - } - } - - private fun isOppositeValueAfterScroll(delta: Float): Boolean = - rotaryScrollDistance * delta < 0f && (abs(delta) < abs(rotaryScrollDistance)) - - private fun isNewScrollEvent(timestamp: Long): Boolean { - val timeDelta = timestamp - previousScrollEventTime - return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime - } - - private fun resetTracking(timestamp: Long) { - scrollBehavior = scrollBehaviorFactory() - rotaryFlingBehavior = rotaryFlingBehaviorFactory() - rotaryFlingBehavior?.startFlingTracking(timestamp) - } -} - -/** - * A scroll handler for Bezel(low-res) without snapping. This scroll handler supports fling. It can - * be set with RotaryFlingBehavior. - */ -internal class LowResRotaryScrollHandler( - private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?, - private val scrollBehaviorFactory: () -> RotaryScrollBehavior, -) : RotaryScrollHandler { - - private val gestureThresholdTime = 200L - private var previousScrollEventTime = 0L - private var rotaryScrollDistance = 0f - - private var scrollJob: Job = CompletableDeferred<Unit>() - private var flingJob: Job = CompletableDeferred<Unit>() - - private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory() - private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory() - - override suspend fun handleScrollEvent( - coroutineScope: CoroutineScope, - event: TimestampedDelta, - rotaryHaptics: RotaryHapticHandler, - ) { - val time = event.timestamp - - if (isNewScrollEvent(time)) { - resetTracking(time) - rotaryScrollDistance = event.delta - } else { - rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta) - rotaryScrollDistance += event.delta - } - - scrollJob.cancel() - flingJob.cancel() - - rotaryHaptics.handleScrollHaptic(event.delta) - debugLog { "Rotary scroll distance: $rotaryScrollDistance" } - - previousScrollEventTime = time - scrollJob = coroutineScope.async { scrollBehavior.handleEvent(rotaryScrollDistance) } - - flingJob = - coroutineScope.async { - rotaryFlingBehavior?.trackFling( - beforeFling = { - debugLog { "Calling before fling section" } - scrollJob.cancel() - scrollBehavior = scrollBehaviorFactory() - }, - ) - } - } - - private fun isNewScrollEvent(timestamp: Long): Boolean { - val timeDelta = timestamp - previousScrollEventTime - return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime - } - - private fun resetTracking(timestamp: Long) { - scrollBehavior = scrollBehaviorFactory() - debugLog { "Velocity tracker reset" } - rotaryFlingBehavior = rotaryFlingBehaviorFactory() - rotaryFlingBehavior?.startFlingTracking(timestamp) - } -} - -/** - * A scroll handler for RSB(high-res) with snapping and without fling Snapping happens after a - * threshold is reached ( set in [RotarySnapBehavior]) - * - * This scroll handler doesn't support fling. - */ -internal class HighResSnapHandler( - private val resistanceFactor: Float, - private val thresholdBehaviorFactory: () -> ThresholdBehavior, - private val snapBehaviourFactory: () -> RotarySnapBehavior, - private val scrollBehaviourFactory: () -> RotaryScrollBehavior, -) : RotaryScrollHandler { - private val gestureThresholdTime = 200L - private val snapDelay = 100L - private val maxSnapsPerEvent = 2 - - private var scrollJob: Job = CompletableDeferred<Unit>() - private var snapJob: Job = CompletableDeferred<Unit>() - - private var previousScrollEventTime = 0L - private var snapAccumulator = 0f - private var rotaryScrollDistance = 0f - private var scrollInProgress = false - - private var snapBehaviour = snapBehaviourFactory() - private var scrollBehaviour = scrollBehaviourFactory() - private var thresholdBehavior = thresholdBehaviorFactory() - - private val scrollEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f) - - override suspend fun handleScrollEvent( - coroutineScope: CoroutineScope, - event: TimestampedDelta, - rotaryHaptics: RotaryHapticHandler, - ) { - val time = event.timestamp - - if (isNewScrollEvent(time)) { - debugLog { "New scroll event" } - resetTracking() - snapJob.cancel() - snapBehaviour = snapBehaviourFactory() - scrollBehaviour = scrollBehaviourFactory() - thresholdBehavior = thresholdBehaviorFactory() - thresholdBehavior.startThresholdTracking(time) - snapAccumulator = 0f - rotaryScrollDistance = 0f - } - - if (!isOppositeValueAfterScroll(event.delta)) { - thresholdBehavior.observeEvent(event.timestamp, event.delta) - } else { - debugLog { "Opposite value after scroll :${event.delta}" } - } - - thresholdBehavior.applySmoothing() - val snapThreshold = thresholdBehavior.snapThreshold() - - snapAccumulator += event.delta - if (!snapJob.isActive) { - val resistanceCoeff = - 1 - scrollEasing.transform(rotaryScrollDistance.absoluteValue / snapThreshold) - rotaryScrollDistance += event.delta * resistanceCoeff - } - - debugLog { "Snap accumulator: $snapAccumulator" } - debugLog { "Rotary scroll distance: $rotaryScrollDistance" } - - debugLog { "snapThreshold: $snapThreshold" } - previousScrollEventTime = time - - if (abs(snapAccumulator) > snapThreshold) { - scrollInProgress = false - scrollBehaviour = scrollBehaviourFactory() - scrollJob.cancel() - - val snapDistance = - (snapAccumulator / snapThreshold) - .toInt() - .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent) - snapAccumulator -= snapThreshold * snapDistance - val sequentialSnap = snapJob.isActive - - debugLog { - "Snap threshold reached: snapDistance:$snapDistance, " + - "sequentialSnap: $sequentialSnap, " + - "snap accumulator remaining: $snapAccumulator" - } - if ( - (!snapBehaviour.topEdgeReached() && snapDistance < 0) || - (!snapBehaviour.bottomEdgeReached() && snapDistance > 0) - ) { - rotaryHaptics.handleSnapHaptic(event.delta) - } - - snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap) - if (!snapJob.isActive) { - snapJob.cancel() - snapJob = - coroutineScope.async { - debugLog { "Snap started" } - try { - snapBehaviour.snapToTargetItem() - } finally { - debugLog { "Snap called finally" } - } - } - } - rotaryScrollDistance = 0f - } else { - if (!snapJob.isActive) { - scrollJob.cancel() - debugLog { "Scrolling for $rotaryScrollDistance/$resistanceFactor px" } - scrollJob = - coroutineScope.async { - scrollBehaviour.handleEvent(rotaryScrollDistance / resistanceFactor) - } - delay(snapDelay) - scrollInProgress = false - scrollBehaviour = scrollBehaviourFactory() - rotaryScrollDistance = 0f - snapAccumulator = 0f - snapBehaviour.prepareSnapForItems(0, false) - - snapJob.cancel() - snapJob = coroutineScope.async { snapBehaviour.snapToClosestItem() } - } - } - } - - private fun isOppositeValueAfterScroll(delta: Float): Boolean = - sign(rotaryScrollDistance) * sign(delta) == -1f && (abs(delta) < abs(rotaryScrollDistance)) - - private fun isNewScrollEvent(timestamp: Long): Boolean { - val timeDelta = timestamp - previousScrollEventTime - return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime - } - - private fun resetTracking() { - scrollInProgress = true - } -} - -/** - * A scroll handler for RSB(high-res) with snapping and without fling Snapping happens after a - * threshold is reached ( set in [RotarySnapBehavior]) - * - * This scroll handler doesn't support fling. - */ -internal class LowResSnapHandler( - private val snapBehaviourFactory: () -> RotarySnapBehavior, -) : RotaryScrollHandler { - private val gestureThresholdTime = 200L - - private var snapJob: Job = CompletableDeferred<Unit>() - - private var previousScrollEventTime = 0L - private var snapAccumulator = 0f - private var scrollInProgress = false - - private var snapBehaviour = snapBehaviourFactory() - - override suspend fun handleScrollEvent( - coroutineScope: CoroutineScope, - event: TimestampedDelta, - rotaryHaptics: RotaryHapticHandler, - ) { - val time = event.timestamp - - if (isNewScrollEvent(time)) { - debugLog { "New scroll event" } - resetTracking() - snapJob.cancel() - snapBehaviour = snapBehaviourFactory() - snapAccumulator = 0f - } - - snapAccumulator += event.delta - - debugLog { "Snap accumulator: $snapAccumulator" } - - previousScrollEventTime = time - - if (abs(snapAccumulator) > 1f) { - scrollInProgress = false - - val snapDistance = sign(snapAccumulator).toInt() - rotaryHaptics.handleSnapHaptic(event.delta) - val sequentialSnap = snapJob.isActive - debugLog { - "Snap threshold reached: snapDistance:$snapDistance, " + - "sequentialSnap: $sequentialSnap, " + - "snap accumulator: $snapAccumulator" - } - - snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap) - if (!snapJob.isActive) { - snapJob.cancel() - snapJob = - coroutineScope.async { - debugLog { "Snap started" } - try { - snapBehaviour.snapToTargetItem() - } finally { - debugLog { "Snap called finally" } - } - } - } - snapAccumulator = 0f - } - } - - private fun isNewScrollEvent(timestamp: Long): Boolean { - val timeDelta = timestamp - previousScrollEventTime - return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime - } - - private fun resetTracking() { - scrollInProgress = true - } -} - -internal class ThresholdBehavior( - private val rotaryScrollAdapter: RotaryScrollAdapter, - private val thresholdDivider: Float, - private val minVelocity: Float = 300f, - private val maxVelocity: Float = 3000f, - private val smoothingConstant: Float = 0.4f, -) { - private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f) - - private val rotaryVelocityTracker = RotaryVelocityTracker() - - private var smoothedVelocity = 0f - - fun startThresholdTracking(time: Long) { - rotaryVelocityTracker.start(time) - smoothedVelocity = 0f - } - - fun observeEvent(timestamp: Long, delta: Float) { - rotaryVelocityTracker.move(timestamp, delta) - } - - fun applySmoothing() { - if (rotaryVelocityTracker.velocity != 0.0f) { - // smooth the velocity - smoothedVelocity = - exponentialSmoothing( - currentVelocity = rotaryVelocityTracker.velocity.absoluteValue, - prevVelocity = smoothedVelocity, - smoothingConstant = smoothingConstant, - ) - } - debugLog { "rotaryVelocityTracker velocity: ${rotaryVelocityTracker.velocity}" } - debugLog { "SmoothedVelocity: $smoothedVelocity" } - } - - fun snapThreshold(): Float { - val thresholdDividerFraction = - thresholdDividerEasing.transform( - inverseLerp( - minVelocity, - maxVelocity, - smoothedVelocity, - ), - ) - return rotaryScrollAdapter.averageItemSize() / - lerp( - 1f, - thresholdDivider, - thresholdDividerFraction, - ) - } - - private fun exponentialSmoothing( - currentVelocity: Float, - prevVelocity: Float, - smoothingConstant: Float, - ): Float = smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity -} - -private data class RotaryHandlerElement( - private val rotaryScrollHandler: RotaryScrollHandler, - private val reverseDirection: Boolean, - private val rotaryHaptics: RotaryHapticHandler, - private val inspectorInfo: InspectorInfo.() -> Unit, -) : ModifierNodeElement<RotaryInputNode>() { - override fun create(): RotaryInputNode = - RotaryInputNode( - rotaryScrollHandler, - reverseDirection, - rotaryHaptics, - ) - - override fun update(node: RotaryInputNode) { - debugLog { "Update launched!" } - node.rotaryScrollHandler = rotaryScrollHandler - node.reverseDirection = reverseDirection - node.rotaryHaptics = rotaryHaptics - } - - override fun InspectorInfo.inspectableProperties() { - inspectorInfo() - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as RotaryHandlerElement - - if (rotaryScrollHandler != other.rotaryScrollHandler) return false - if (reverseDirection != other.reverseDirection) return false - if (rotaryHaptics != other.rotaryHaptics) return false - if (inspectorInfo != other.inspectorInfo) return false - - return true - } - - override fun hashCode(): Int { - var result = rotaryScrollHandler.hashCode() - result = 31 * result + reverseDirection.hashCode() - result = 31 * result + rotaryHaptics.hashCode() - result = 31 * result + inspectorInfo.hashCode() - return result - } -} - -private class RotaryInputNode( - var rotaryScrollHandler: RotaryScrollHandler, - var reverseDirection: Boolean, - var rotaryHaptics: RotaryHapticHandler, -) : RotaryInputModifierNode, Modifier.Node() { - - val channel = Channel<TimestampedDelta>(capacity = Channel.CONFLATED) - val flow = channel.receiveAsFlow() - - override fun onAttach() { - coroutineScope.launch { - flow.collectLatest { - debugLog { - "Scroll event received: " + "delta:${it.delta}, timestamp:${it.timestamp}" - } - rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics) - } - } - } - - override fun onRotaryScrollEvent(event: RotaryScrollEvent): Boolean = false - - override fun onPreRotaryScrollEvent(event: RotaryScrollEvent): Boolean { - debugLog { "onPreRotaryScrollEvent" } - channel.trySend( - TimestampedDelta( - event.uptimeMillis, - event.verticalScrollPixels * if (reverseDirection) -1f else 1f, - ), - ) - return true - } -} - -private fun inverseLerp(start: Float, stop: Float, value: Float): Float { - return ((value - start) / (stop - start)).coerceIn(0f, 1f) -} - -/** Debug logging that can be enabled. */ -private const val DEBUG = false - -private inline fun debugLog(generateMsg: () -> String) { - if (DEBUG) { - println("RotaryScroll: ${generateMsg()}") - } -} diff --git a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt b/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt deleted file mode 100644 index 1719ecef3..000000000 --- a/PermissionController/src/com/android/permissioncontroller/permission/ui/wear/elements/rotaryinput/RotaryVelocityTracker.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.permissioncontroller.permission.ui.wear.elements.rotaryinput - -import androidx.compose.ui.input.pointer.util.VelocityTracker1D - -// This file is a copy of RotaryVelocityTracker.kt from Horologist (go/horologist), -// remove it once Wear Compose 1.4 is landed (b/325560444). - -/** A wrapper around VelocityTracker1D to provide support for rotary input. */ -class RotaryVelocityTracker { - private var velocityTracker: VelocityTracker1D = VelocityTracker1D(true) - - /** Retrieve the last computed velocity. */ - val velocity: Float - get() = velocityTracker.calculateVelocity() - - /** Start tracking motion. */ - fun start(currentTime: Long) { - velocityTracker.resetTracking() - velocityTracker.addDataPoint(currentTime, 0f) - } - - /** Continue tracking motion as the input rotates. */ - fun move(currentTime: Long, delta: Float) { - velocityTracker.addDataPoint(currentTime, delta) - } - - /** Stop tracking motion. */ - fun end() { - velocityTracker.resetTracking() - } -} |