1 /*
<lambda>null2  * Copyright 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      https://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 @file:OptIn(ExperimentalHorologistApi::class)
18 
19 package com.google.android.horologist.compose.rotaryinput
20 
21 import android.os.Build
22 import android.view.HapticFeedbackConstants
23 import android.view.View
24 import androidx.compose.foundation.gestures.ScrollableState
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.LaunchedEffect
27 import androidx.compose.runtime.remember
28 import androidx.compose.ui.platform.LocalView
29 import com.google.android.horologist.annotations.ExperimentalHorologistApi
30 import kotlinx.coroutines.Dispatchers
31 import kotlinx.coroutines.channels.BufferOverflow
32 import kotlinx.coroutines.channels.Channel
33 import kotlinx.coroutines.delay
34 import kotlinx.coroutines.flow.Flow
35 import kotlinx.coroutines.flow.conflate
36 import kotlinx.coroutines.flow.flow
37 import kotlinx.coroutines.flow.receiveAsFlow
38 import kotlinx.coroutines.withContext
39 import kotlin.math.abs
40 
41 private const val DEBUG = false
42 
43 /**
44  * Debug logging that can be enabled.
45  */
46 private inline fun debugLog(generateMsg: () -> String) {
47     if (DEBUG) {
48         println("RotaryHaptics: ${generateMsg()}")
49     }
50 }
51 
52 /**
53  * Throttling events within specified timeframe. Only first and last events will be received.
54  * For a flow emitting elements 1 to 30, with a 100ms delay between them:
55  * ```
56  * val flow = flow {
57  *     for (i in 1..30) {
58  *         delay(100)
59  *         emit(i)
60  *     }
61  * }
62  * ```
63  * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 .
64  */
throttleLatestnull65 internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> =
66         flow {
67             conflate().collect {
68                 emit(it)
69                 delay(timeframe)
70             }
71         }
72 
73 /**
74  * Handles haptics for rotary usage
75  */
76 @ExperimentalHorologistApi
77 public interface RotaryHapticHandler {
78 
79     /**
80      * Handles haptics when scroll is used
81      */
82     @ExperimentalHorologistApi
handleScrollHapticnull83     public fun handleScrollHaptic(scrollDelta: Float)
84 
85     /**
86      * Handles haptics when scroll with snap is used
87      */
88     @ExperimentalHorologistApi
89     public fun handleSnapHaptic(scrollDelta: Float)
90 }
91 
92 /**
93  * Default implementation of [RotaryHapticHandler]. It handles haptic feedback based
94  * on the [scrollableState], scrolled pixels and [hapticsThresholdPx].
95  * Haptic is not fired in this class, instead it's sent to [hapticsChannel]
96  * where it'll performed later.
97  *
98  * @param scrollableState Haptic performed based on this state
99  * @param hapticsChannel Channel to which haptic events will be sent
100  * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
101  */
102 public class DefaultRotaryHapticHandler(
103         private val scrollableState: ScrollableState,
104         private val hapticsChannel: Channel<RotaryHapticsType>,
105         private val hapticsThresholdPx: Long = 50,
106 ) : RotaryHapticHandler {
107 
108     private var overscrollHapticTriggered = false
109     private var currScrollPosition = 0f
110     private var prevHapticsPosition = 0f
111 
112     override fun handleScrollHaptic(scrollDelta: Float) {
113         if ((scrollDelta > 0 && !scrollableState.canScrollForward) ||
114                 (scrollDelta < 0 && !scrollableState.canScrollBackward)
115         ) {
116             if (!overscrollHapticTriggered) {
117                 trySendHaptic(RotaryHapticsType.ScrollLimit)
118                 overscrollHapticTriggered = true
119             }
120         } else {
121             overscrollHapticTriggered = false
122             currScrollPosition += scrollDelta
123             val diff = abs(currScrollPosition - prevHapticsPosition)
124 
125             if (diff >= hapticsThresholdPx) {
126                 trySendHaptic(RotaryHapticsType.ScrollTick)
127                 prevHapticsPosition = currScrollPosition
128             }
129         }
130     }
131 
132     override fun handleSnapHaptic(scrollDelta: Float) {
133         if ((scrollDelta > 0 && !scrollableState.canScrollForward) ||
134                 (scrollDelta < 0 && !scrollableState.canScrollBackward)
135         ) {
136             if (!overscrollHapticTriggered) {
137                 trySendHaptic(RotaryHapticsType.ScrollLimit)
138                 overscrollHapticTriggered = true
139             }
140         } else {
141             overscrollHapticTriggered = false
142             trySendHaptic(RotaryHapticsType.ScrollItemFocus)
143         }
144     }
145 
146     private fun trySendHaptic(rotaryHapticsType: RotaryHapticsType) {
147         // Ok to ignore the ChannelResult because we default to capacity = 2 and DROP_OLDEST
148         @Suppress("UNUSED_VARIABLE")
149         val unused = hapticsChannel.trySend(rotaryHapticsType)
150     }
151 }
152 
153 /**
154  * Interface for Rotary haptic feedback
155  */
156 @ExperimentalHorologistApi
157 public interface RotaryHapticFeedback {
158     @ExperimentalHorologistApi
performHapticFeedbacknull159     public fun performHapticFeedback(type: RotaryHapticsType)
160 }
161 
162 /**
163  * Rotary haptic types
164  */
165 @ExperimentalHorologistApi
166 @JvmInline
167 public value class RotaryHapticsType(private val type: Int) {
168     public companion object {
169         /**
170          * A scroll ticking haptic. Similar to texture haptic - performed each time when
171          * a scrollable content is scrolled by a certain distance
172          */
173         @ExperimentalHorologistApi
174         public val ScrollTick: RotaryHapticsType = RotaryHapticsType(1)
175 
176         /**
177          * An item focus (snap) haptic. Performed when a scrollable content is snapped
178          * to a specific item.
179          */
180         @ExperimentalHorologistApi
181         public val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2)
182 
183         /**
184          * A limit(overscroll) haptic. Performed when a list reaches the limit
185          * (start or end) and can't scroll further
186          */
187         @ExperimentalHorologistApi
188         public val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3)
189     }
190 }
191 
192 /**
193  * Remember disabled haptics handler
194  */
195 @ExperimentalHorologistApi
196 @Composable
<lambda>null197 public fun rememberDisabledHaptic(): RotaryHapticHandler = remember {
198     object : RotaryHapticHandler {
199 
200         override fun handleScrollHaptic(scrollDelta: Float) {
201             // Do nothing
202         }
203 
204         override fun handleSnapHaptic(scrollDelta: Float) {
205             // Do nothing
206         }
207     }
208 }
209 
210 /**
211  * Remember rotary haptic handler.
212  * @param scrollableState A scrollableState, used to determine whether the end of the scrollable
213  * was reached or not.
214  * @param throttleThresholdMs Throttling events within specified timeframe.
215  * Only first and last events will be received. Check [throttleLatest] for more info.
216  * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
217  * @param hapticsChannel Channel to which haptic events will be sent
218  * @param rotaryHaptics Interface for Rotary haptic feedback which performs haptics
219  */
220 @ExperimentalHorologistApi
221 @Composable
rememberRotaryHapticHandlernull222 public fun rememberRotaryHapticHandler(
223         scrollableState: ScrollableState,
224         throttleThresholdMs: Long = 30,
225         hapticsThresholdPx: Long = 50,
226         hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel(),
227         rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback(),
228 ): RotaryHapticHandler {
229     return remember(scrollableState, hapticsChannel, rotaryHaptics) {
230         DefaultRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx)
231     }.apply {
232         LaunchedEffect(hapticsChannel) {
233             hapticsChannel.receiveAsFlow()
234                     .throttleLatest(throttleThresholdMs)
235                     .collect { hapticType ->
236                         // 'withContext' launches performHapticFeedback in a separate thread,
237                         // as otherwise it produces a visible lag (b/219776664)
238                         val currentTime = System.currentTimeMillis()
239                         debugLog { "Haptics started" }
240                         withContext(Dispatchers.Default) {
241                             debugLog {
242                                 "Performing haptics, delay: " +
243                                         "${System.currentTimeMillis() - currentTime}"
244                             }
245                             rotaryHaptics.performHapticFeedback(hapticType)
246                         }
247                     }
248         }
249     }
250 }
251 
252 @Composable
rememberHapticChannelnull253 private fun rememberHapticChannel() =
254         remember {
255             Channel<RotaryHapticsType>(
256                     capacity = 2,
257                     onBufferOverflow = BufferOverflow.DROP_OLDEST,
258             )
259         }
260 
261 @ExperimentalHorologistApi
262 @Composable
rememberDefaultRotaryHapticFeedbacknull263 public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback =
264         LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } }
265 
findDeviceSpecificHapticFeedbacknull266 internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback =
267         if (isGooglePixelWatch()) {
268             PixelWatchRotaryHapticFeedback(view)
269         } else if (isGalaxyWatchClassic()) {
270             GalaxyWatchClassicHapticFeedback(view)
271         } else {
272             DefaultRotaryHapticFeedback(view)
273         }
274 
275 /**
276  * Default Rotary implementation for [RotaryHapticFeedback]
277  */
278 @ExperimentalHorologistApi
279 public class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
280 
281     @ExperimentalHorologistApi
performHapticFeedbacknull282     override fun performHapticFeedback(
283             type: RotaryHapticsType,
284     ) {
285         when (type) {
286             RotaryHapticsType.ScrollItemFocus -> {
287                 view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
288             }
289 
290             RotaryHapticsType.ScrollTick -> {
291                 view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
292             }
293 
294             RotaryHapticsType.ScrollLimit -> {
295                 view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
296             }
297         }
298     }
299 }
300 
301 /**
302  * Implementation of [RotaryHapticFeedback] for Pixel Watch
303  */
304 @ExperimentalHorologistApi
305 private class PixelWatchRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
306 
307     @ExperimentalHorologistApi
performHapticFeedbacknull308     override fun performHapticFeedback(
309             type: RotaryHapticsType,
310     ) {
311         when (type) {
312             RotaryHapticsType.ScrollItemFocus -> {
313                 view.performHapticFeedback(
314                         if (Build.VERSION.SDK_INT >= 33) {
315                             ROTARY_SCROLL_ITEM_FOCUS
316                         } else {
317                             WEAR_SCROLL_ITEM_FOCUS
318                         },
319                 )
320             }
321 
322             RotaryHapticsType.ScrollTick -> {
323                 view.performHapticFeedback(
324                         if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_TICK else WEAR_SCROLL_TICK,
325                 )
326             }
327 
328             RotaryHapticsType.ScrollLimit -> {
329                 view.performHapticFeedback(
330                         if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_LIMIT else WEAR_SCROLL_LIMIT,
331                 )
332             }
333         }
334     }
335 
336     private companion object {
337         // Hidden constants from HapticFeedbackConstants.java specific for Pixel Watch
338         // API 33
339         public const val ROTARY_SCROLL_TICK: Int = 18
340         public const val ROTARY_SCROLL_ITEM_FOCUS: Int = 19
341         public const val ROTARY_SCROLL_LIMIT: Int = 20
342 
343         // API 30
344         public const val WEAR_SCROLL_TICK: Int = 10002
345         public const val WEAR_SCROLL_ITEM_FOCUS: Int = 10003
346         public const val WEAR_SCROLL_LIMIT: Int = 10003
347     }
348 }
349 
350 /**
351  * Implementation of [RotaryHapticFeedback] for Galaxy Watch 4 Classic
352  */
353 @ExperimentalHorologistApi
354 private class GalaxyWatchClassicHapticFeedback(private val view: View) : RotaryHapticFeedback {
355 
356     @ExperimentalHorologistApi
performHapticFeedbacknull357     override fun performHapticFeedback(
358             type: RotaryHapticsType,
359     ) {
360         when (type) {
361             RotaryHapticsType.ScrollItemFocus -> {
362                 // No haptic for scroll snap ( we have physical bezel)
363             }
364 
365             RotaryHapticsType.ScrollTick -> {
366                 // No haptic for scroll tick ( we have physical bezel)
367             }
368 
369             RotaryHapticsType.ScrollLimit -> {
370                 view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
371             }
372         }
373     }
374 }
375 
isGalaxyWatchClassicnull376 private fun isGalaxyWatchClassic(): Boolean =
377         Build.MODEL.matches("SM-R8[89]5.".toRegex())
378 
379 private fun isGooglePixelWatch(): Boolean =
380         Build.MODEL.startsWith("Google Pixel Watch")
381