Skip to content

Commit b8d596f

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

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2026 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+
* http://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+
package androidx.compose.ui.platform
18+
19+
import androidx.compose.ui.hapticfeedback.HapticFeedback
20+
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
21+
import kotlin.js.ExperimentalWasmJsInterop
22+
import kotlin.js.JsArray
23+
import kotlin.js.JsNumber
24+
import kotlin.js.js
25+
import kotlin.js.toJsArray
26+
import kotlin.js.toJsNumber
27+
28+
internal class WebHapticFeedback : HapticFeedback {
29+
@OptIn(ExperimentalWasmJsInterop::class)
30+
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
31+
vibrate(vibrationPatternFor(hapticFeedbackType))
32+
}
33+
}
34+
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)
54+
}
55+
56+
@OptIn(ExperimentalWasmJsInterop::class)
57+
private fun vibrationPatternOf(vararg durations: Int): JsArray<JsNumber> =
58+
durations.map { it.toDouble().toJsNumber() }.toJsArray()
59+
60+
//language=javascript
61+
@OptIn(ExperimentalWasmJsInterop::class)
62+
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+
)
72+
}

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ import androidx.compose.ui.internal.focusExt
5353
import androidx.compose.ui.navigationevent.BackNavigationEventInput
5454
import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner
5555
import androidx.compose.ui.platform.DefaultInputModeManager
56+
import androidx.compose.ui.platform.LocalHapticFeedback
5657
import androidx.compose.ui.platform.PlatformContext
5758
import androidx.compose.ui.platform.PlatformDragAndDropManager
5859
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
5960
import androidx.compose.ui.platform.TextToolbar
6061
import androidx.compose.ui.platform.ViewConfiguration
62+
import androidx.compose.ui.platform.WebHapticFeedback
6163
import androidx.compose.ui.platform.WebTextInputService
6264
import androidx.compose.ui.platform.WebTextToolbar
6365
import androidx.compose.ui.platform.WebWakeLockManager
@@ -204,6 +206,8 @@ internal class ComposeWindow(
204206
@VisibleForTesting
205207
internal val archComponentsOwner = DefaultArchitectureComponentsOwner()
206208

209+
private val hapticFeedback = WebHapticFeedback()
210+
207211
private val navigationEventInput = BackNavigationEventInput()
208212

209213
private val canvasEvents = EventTargetListener(canvas)
@@ -464,6 +468,7 @@ internal class ComposeWindow(
464468
scene.setContent {
465469
CompositionLocalProvider(
466470
LocalSystemTheme provides systemThemeObserver.currentSystemTheme.value,
471+
LocalHapticFeedback provides hapticFeedback,
467472
LocalInteropContainer provides interopContainer,
468473
LocalActiveClipEventsTarget provides clipEventsTargetProvider,
469474
content = {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)
2+
3+
/*
4+
* Copyright 2026 The Android Open Source Project
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package androidx.compose.ui.platform
20+
21+
import androidx.compose.ui.OnCanvasTests
22+
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
23+
import kotlin.test.Test
24+
import kotlin.test.assertContentEquals
25+
import kotlin.test.assertIs
26+
27+
class WebHapticFeedbackTest : OnCanvasTests {
28+
29+
@Test
30+
fun composeWindowProvidesWebHapticFeedback() = runApplicationTest {
31+
var hapticFeedback: Any? = null
32+
33+
createComposeWindow {
34+
hapticFeedback = LocalHapticFeedback.current
35+
}
36+
37+
assertIs<WebHapticFeedback>(hapticFeedback)
38+
}
39+
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+
}
67+
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+
)
74+
}
75+
}

0 commit comments

Comments
 (0)