Skip to content

Commit 9774db8

Browse files
committed
Add support for web haptic feedback in Compose UI
1 parent b8d596f commit 9774db8

2 files changed

Lines changed: 59 additions & 68 deletions

File tree

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -25,48 +25,64 @@ import kotlin.js.js
2525
import kotlin.js.toJsArray
2626
import kotlin.js.toJsNumber
2727

28+
@OptIn(ExperimentalWasmJsInterop::class)
2829
internal class WebHapticFeedback : HapticFeedback {
29-
@OptIn(ExperimentalWasmJsInterop::class)
30+
// Check if API is supported before doing anything
31+
private val isVibrationSupported = isVibrationSupported()
32+
33+
// Declare these hardcoded patterns to avoid js-interop on every call
34+
private val ConfirmVibrationPattern: JsArray<JsNumber> = vibrationPatternOf(18, 32, 36)
35+
private val RejectVibrationPattern: JsArray<JsNumber> = vibrationPatternOf(18, 28, 18, 28, 18)
36+
private val SinglePulseVibrationPattern: JsArray<JsNumber> = vibrationPatternOf(12)
37+
private val SoftTickVibrationPattern: JsArray<JsNumber> = vibrationPatternOf(6)
38+
3039
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
31-
vibrate(vibrationPatternFor(hapticFeedbackType))
40+
if (!isVibrationSupported) return
41+
val pattern = vibrationPatternFor(hapticFeedbackType) ?: return
42+
vibrate(pattern)
3243
}
33-
}
3444

35-
// TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics
36-
// and rely on it once it's implemented
37-
@OptIn(ExperimentalWasmJsInterop::class)
38-
internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray<JsNumber> =
39-
when (hapticFeedbackType) {
40-
HapticFeedbackType.Confirm -> vibrationPatternOf(18, 32, 36)
41-
HapticFeedbackType.Reject -> vibrationPatternOf(18, 28, 18, 28, 18)
42-
HapticFeedbackType.ContextClick,
43-
HapticFeedbackType.GestureEnd,
44-
HapticFeedbackType.GestureThresholdActivate,
45-
HapticFeedbackType.LongPress,
46-
HapticFeedbackType.ToggleOff,
47-
HapticFeedbackType.ToggleOn,
48-
HapticFeedbackType.VirtualKey -> vibrationPatternOf(12)
49-
HapticFeedbackType.KeyboardTap,
50-
HapticFeedbackType.SegmentFrequentTick,
51-
HapticFeedbackType.SegmentTick,
52-
HapticFeedbackType.TextHandleMove -> vibrationPatternOf(6)
53-
else -> vibrationPatternOf(12)
45+
// We don't have a high-level browser API right now. So we hardcode the patterns here.
46+
// TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics
47+
// and rely on it once it's implemented
48+
@OptIn(ExperimentalWasmJsInterop::class)
49+
internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray<JsNumber>? {
50+
return when (hapticFeedbackType) {
51+
HapticFeedbackType.Confirm -> ConfirmVibrationPattern
52+
HapticFeedbackType.Reject -> RejectVibrationPattern
53+
HapticFeedbackType.ContextClick,
54+
HapticFeedbackType.GestureEnd,
55+
HapticFeedbackType.GestureThresholdActivate,
56+
HapticFeedbackType.LongPress,
57+
HapticFeedbackType.ToggleOff,
58+
HapticFeedbackType.ToggleOn,
59+
HapticFeedbackType.VirtualKey -> SinglePulseVibrationPattern
60+
HapticFeedbackType.KeyboardTap,
61+
HapticFeedbackType.SegmentFrequentTick,
62+
HapticFeedbackType.SegmentTick -> SoftTickVibrationPattern
63+
HapticFeedbackType.TextHandleMove -> null
64+
else -> null
65+
}
5466
}
67+
}
5568

5669
@OptIn(ExperimentalWasmJsInterop::class)
5770
private fun vibrationPatternOf(vararg durations: Int): JsArray<JsNumber> =
5871
durations.map { it.toDouble().toJsNumber() }.toJsArray()
5972

73+
@OptIn(ExperimentalWasmJsInterop::class)
74+
private fun isVibrationSupported(): Boolean = js(
75+
//language=javascript
76+
"""
77+
typeof window !== 'undefined' &&
78+
window.navigator != null &&
79+
typeof window.navigator.vibrate === 'function'
80+
"""
81+
)
82+
6083
//language=javascript
6184
@OptIn(ExperimentalWasmJsInterop::class)
6285
private fun vibrate(pattern: JsArray<JsNumber>) {
63-
js(
64-
"""
65-
if (typeof window !== 'undefined' &&
66-
window.navigator != null &&
67-
typeof window.navigator.vibrate === 'function') {
68-
window.navigator.vibrate(pattern)
69-
}
70-
"""
71-
)
86+
// Assuming the API support has been checked in advance, we can safely call it
87+
js("window.navigator.vibrate(pattern)")
7288
}

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,57 +19,32 @@
1919
package androidx.compose.ui.platform
2020

2121
import androidx.compose.ui.OnCanvasTests
22+
import androidx.compose.ui.hapticfeedback.HapticFeedback
2223
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
2324
import kotlin.test.Test
2425
import kotlin.test.assertContentEquals
26+
import kotlin.test.assertEquals
2527
import kotlin.test.assertIs
28+
import kotlin.test.assertNotNull
29+
import kotlin.test.assertNull
2630

2731
class WebHapticFeedbackTest : OnCanvasTests {
2832

2933
@Test
3034
fun composeWindowProvidesWebHapticFeedback() = runApplicationTest {
31-
var hapticFeedback: Any? = null
35+
var hapticFeedback: WebHapticFeedback? = null
3236

3337
createComposeWindow {
34-
hapticFeedback = LocalHapticFeedback.current
38+
hapticFeedback = LocalHapticFeedback.current as? WebHapticFeedback
3539
}
3640

37-
assertIs<WebHapticFeedback>(hapticFeedback)
38-
}
41+
assertNotNull(hapticFeedback, "LocalHapticFeedback should provide WebHapticFeedback")
42+
val pattern = hapticFeedback!!.vibrationPatternFor(HapticFeedbackType.Confirm)
3943

40-
@Test
41-
fun mapsConfirmToMultiPulsePattern() {
42-
assertPatternEquals(
43-
expected = listOf(18, 32, 36),
44-
actual = vibrationPatternFor(HapticFeedbackType.Confirm)
45-
)
46-
}
47-
48-
@Test
49-
fun mapsRejectToErrorPattern() {
50-
assertPatternEquals(
51-
expected = listOf(18, 28, 18, 28, 18),
52-
actual = vibrationPatternFor(HapticFeedbackType.Reject)
53-
)
54-
}
55-
56-
@Test
57-
fun mapsSelectionAndTextHandleTypesToShortPulse() {
58-
assertPatternEquals(
59-
expected = listOf(6),
60-
actual = vibrationPatternFor(HapticFeedbackType.SegmentTick)
61-
)
62-
assertPatternEquals(
63-
expected = listOf(6),
64-
actual = vibrationPatternFor(HapticFeedbackType.TextHandleMove)
65-
)
66-
}
44+
assertNotNull(pattern, "pattern should not be null")
6745

68-
private fun assertPatternEquals(expected: List<Int>, actual: dynamic) {
69-
val actualValues = js("Array.from(actual)").unsafeCast<Array<Double>>()
70-
assertContentEquals(
71-
expected.toTypedArray(),
72-
actualValues.map(Double::toInt).toTypedArray()
73-
)
46+
// We can't verify the vibration has been performed,
47+
// so just call performHapticFeedback to check that it doesn't fail
48+
hapticFeedback!!.performHapticFeedback(HapticFeedbackType.Confirm)
7449
}
7550
}

0 commit comments

Comments
 (0)