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