From eac401bcadf54e8b6ec6ba45ac526930193b7bc1 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Mon, 11 May 2026 21:20:13 +0200 Subject: [PATCH 1/5] Add support for web haptic feedback in Compose UI --- .../ui/platform/DefaultHapticFeedback.web.kt | 72 ++++++++++++++++++ .../ui/window/ComposeWindowInternal.web.kt | 5 ++ .../ui/platform/WebHapticFeedbackTest.kt | 75 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt create mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt new file mode 100644 index 0000000000000..b536b7e03940d --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 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 androidx.compose.ui.platform + +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsArray +import kotlin.js.JsNumber +import kotlin.js.js +import kotlin.js.toJsArray +import kotlin.js.toJsNumber + +internal class WebHapticFeedback : HapticFeedback { + @OptIn(ExperimentalWasmJsInterop::class) + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + vibrate(vibrationPatternFor(hapticFeedbackType)) + } +} + +// TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics +// and rely on it once it's implemented +@OptIn(ExperimentalWasmJsInterop::class) +internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray = + when (hapticFeedbackType) { + HapticFeedbackType.Confirm -> vibrationPatternOf(18, 32, 36) + HapticFeedbackType.Reject -> vibrationPatternOf(18, 28, 18, 28, 18) + HapticFeedbackType.ContextClick, + HapticFeedbackType.GestureEnd, + HapticFeedbackType.GestureThresholdActivate, + HapticFeedbackType.LongPress, + HapticFeedbackType.ToggleOff, + HapticFeedbackType.ToggleOn, + HapticFeedbackType.VirtualKey -> vibrationPatternOf(12) + HapticFeedbackType.KeyboardTap, + HapticFeedbackType.SegmentFrequentTick, + HapticFeedbackType.SegmentTick, + HapticFeedbackType.TextHandleMove -> vibrationPatternOf(6) + else -> vibrationPatternOf(12) + } + +@OptIn(ExperimentalWasmJsInterop::class) +private fun vibrationPatternOf(vararg durations: Int): JsArray = + durations.map { it.toDouble().toJsNumber() }.toJsArray() + +//language=javascript +@OptIn(ExperimentalWasmJsInterop::class) +private fun vibrate(pattern: JsArray) { + js( + """ + if (typeof window !== 'undefined' && + window.navigator != null && + typeof window.navigator.vibrate === 'function') { + window.navigator.vibrate(pattern) + } + """ + ) +} \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 8e6679c5bf89f..3ca31b3da921e 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -53,11 +53,13 @@ import androidx.compose.ui.internal.focusExt import androidx.compose.ui.navigationevent.BackNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.DefaultInputModeManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformDragAndDropManager import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.WebHapticFeedback import androidx.compose.ui.platform.WebTextInputService import androidx.compose.ui.platform.WebTextToolbar import androidx.compose.ui.platform.WebWakeLockManager @@ -204,6 +206,8 @@ internal class ComposeWindow( @VisibleForTesting internal val archComponentsOwner = DefaultArchitectureComponentsOwner() + private val hapticFeedback = WebHapticFeedback() + private val navigationEventInput = BackNavigationEventInput() private val canvasEvents = EventTargetListener(canvas) @@ -464,6 +468,7 @@ internal class ComposeWindow( scene.setContent { CompositionLocalProvider( LocalSystemTheme provides systemThemeObserver.currentSystemTheme.value, + LocalHapticFeedback provides hapticFeedback, LocalInteropContainer provides interopContainer, LocalActiveClipEventsTarget provides clipEventsTargetProvider, content = { diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt new file mode 100644 index 0000000000000..785e3abd1714f --- /dev/null +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt @@ -0,0 +1,75 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +/* + * Copyright 2026 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 androidx.compose.ui.platform + +import androidx.compose.ui.OnCanvasTests +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertIs + +class WebHapticFeedbackTest : OnCanvasTests { + + @Test + fun composeWindowProvidesWebHapticFeedback() = runApplicationTest { + var hapticFeedback: Any? = null + + createComposeWindow { + hapticFeedback = LocalHapticFeedback.current + } + + assertIs(hapticFeedback) + } + + @Test + fun mapsConfirmToMultiPulsePattern() { + assertPatternEquals( + expected = listOf(18, 32, 36), + actual = vibrationPatternFor(HapticFeedbackType.Confirm) + ) + } + + @Test + fun mapsRejectToErrorPattern() { + assertPatternEquals( + expected = listOf(18, 28, 18, 28, 18), + actual = vibrationPatternFor(HapticFeedbackType.Reject) + ) + } + + @Test + fun mapsSelectionAndTextHandleTypesToShortPulse() { + assertPatternEquals( + expected = listOf(6), + actual = vibrationPatternFor(HapticFeedbackType.SegmentTick) + ) + assertPatternEquals( + expected = listOf(6), + actual = vibrationPatternFor(HapticFeedbackType.TextHandleMove) + ) + } + + private fun assertPatternEquals(expected: List, actual: dynamic) { + val actualValues = js("Array.from(actual)").unsafeCast>() + assertContentEquals( + expected.toTypedArray(), + actualValues.map(Double::toInt).toTypedArray() + ) + } +} \ No newline at end of file From 5f18a6bbfab54e0dab13aa93bac057140b9ef732 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Mon, 11 May 2026 22:04:05 +0200 Subject: [PATCH 2/5] Add support for web haptic feedback in Compose UI --- .../ui/platform/DefaultHapticFeedback.web.kt | 78 +++++++++++-------- .../ui/platform/WebHapticFeedbackTest.kt | 49 +++--------- 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt index b536b7e03940d..94cfecbaa593e 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -25,48 +25,64 @@ import kotlin.js.js import kotlin.js.toJsArray import kotlin.js.toJsNumber +@OptIn(ExperimentalWasmJsInterop::class) internal class WebHapticFeedback : HapticFeedback { - @OptIn(ExperimentalWasmJsInterop::class) + // Check if API is supported before doing anything + private val isVibrationSupported = isVibrationSupported() + + // Declare these hardcoded patterns to avoid js-interop on every call + private val ConfirmVibrationPattern: JsArray = vibrationPatternOf(18, 32, 36) + private val RejectVibrationPattern: JsArray = vibrationPatternOf(18, 28, 18, 28, 18) + private val SinglePulseVibrationPattern: JsArray = vibrationPatternOf(12) + private val SoftTickVibrationPattern: JsArray = vibrationPatternOf(6) + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { - vibrate(vibrationPatternFor(hapticFeedbackType)) + if (!isVibrationSupported) return + val pattern = vibrationPatternFor(hapticFeedbackType) ?: return + vibrate(pattern) } -} -// TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics -// and rely on it once it's implemented -@OptIn(ExperimentalWasmJsInterop::class) -internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray = - when (hapticFeedbackType) { - HapticFeedbackType.Confirm -> vibrationPatternOf(18, 32, 36) - HapticFeedbackType.Reject -> vibrationPatternOf(18, 28, 18, 28, 18) - HapticFeedbackType.ContextClick, - HapticFeedbackType.GestureEnd, - HapticFeedbackType.GestureThresholdActivate, - HapticFeedbackType.LongPress, - HapticFeedbackType.ToggleOff, - HapticFeedbackType.ToggleOn, - HapticFeedbackType.VirtualKey -> vibrationPatternOf(12) - HapticFeedbackType.KeyboardTap, - HapticFeedbackType.SegmentFrequentTick, - HapticFeedbackType.SegmentTick, - HapticFeedbackType.TextHandleMove -> vibrationPatternOf(6) - else -> vibrationPatternOf(12) + // We don't have a high-level browser API right now. So we hardcode the patterns here. + // TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics + // and rely on it once it's implemented + @OptIn(ExperimentalWasmJsInterop::class) + internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray? { + return when (hapticFeedbackType) { + HapticFeedbackType.Confirm -> ConfirmVibrationPattern + HapticFeedbackType.Reject -> RejectVibrationPattern + HapticFeedbackType.ContextClick, + HapticFeedbackType.GestureEnd, + HapticFeedbackType.GestureThresholdActivate, + HapticFeedbackType.LongPress, + HapticFeedbackType.ToggleOff, + HapticFeedbackType.ToggleOn, + HapticFeedbackType.VirtualKey -> SinglePulseVibrationPattern + HapticFeedbackType.KeyboardTap, + HapticFeedbackType.SegmentFrequentTick, + HapticFeedbackType.SegmentTick -> SoftTickVibrationPattern + HapticFeedbackType.TextHandleMove -> null + else -> null + } } +} @OptIn(ExperimentalWasmJsInterop::class) private fun vibrationPatternOf(vararg durations: Int): JsArray = durations.map { it.toDouble().toJsNumber() }.toJsArray() +@OptIn(ExperimentalWasmJsInterop::class) +private fun isVibrationSupported(): Boolean = js( + //language=javascript + """ + typeof window !== 'undefined' && + window.navigator != null && + typeof window.navigator.vibrate === 'function' + """ +) + //language=javascript @OptIn(ExperimentalWasmJsInterop::class) private fun vibrate(pattern: JsArray) { - js( - """ - if (typeof window !== 'undefined' && - window.navigator != null && - typeof window.navigator.vibrate === 'function') { - window.navigator.vibrate(pattern) - } - """ - ) + // Assuming the API support has been checked in advance, we can safely call it + js("window.navigator.vibrate(pattern)") } \ No newline at end of file diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt index 785e3abd1714f..a6dc0767e6aa9 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt @@ -19,57 +19,32 @@ package androidx.compose.ui.platform import androidx.compose.ui.OnCanvasTests +import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import kotlin.test.Test import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull class WebHapticFeedbackTest : OnCanvasTests { @Test fun composeWindowProvidesWebHapticFeedback() = runApplicationTest { - var hapticFeedback: Any? = null + var hapticFeedback: WebHapticFeedback? = null createComposeWindow { - hapticFeedback = LocalHapticFeedback.current + hapticFeedback = LocalHapticFeedback.current as? WebHapticFeedback } - assertIs(hapticFeedback) - } + assertNotNull(hapticFeedback, "LocalHapticFeedback should provide WebHapticFeedback") + val pattern = hapticFeedback!!.vibrationPatternFor(HapticFeedbackType.Confirm) - @Test - fun mapsConfirmToMultiPulsePattern() { - assertPatternEquals( - expected = listOf(18, 32, 36), - actual = vibrationPatternFor(HapticFeedbackType.Confirm) - ) - } - - @Test - fun mapsRejectToErrorPattern() { - assertPatternEquals( - expected = listOf(18, 28, 18, 28, 18), - actual = vibrationPatternFor(HapticFeedbackType.Reject) - ) - } - - @Test - fun mapsSelectionAndTextHandleTypesToShortPulse() { - assertPatternEquals( - expected = listOf(6), - actual = vibrationPatternFor(HapticFeedbackType.SegmentTick) - ) - assertPatternEquals( - expected = listOf(6), - actual = vibrationPatternFor(HapticFeedbackType.TextHandleMove) - ) - } + assertNotNull(pattern, "pattern should not be null") - private fun assertPatternEquals(expected: List, actual: dynamic) { - val actualValues = js("Array.from(actual)").unsafeCast>() - assertContentEquals( - expected.toTypedArray(), - actualValues.map(Double::toInt).toTypedArray() - ) + // We can't verify the vibration has been performed, + // so just call performHapticFeedback to check that it doesn't fail + hapticFeedback!!.performHapticFeedback(HapticFeedbackType.Confirm) } } \ No newline at end of file From fd9cd87bd15aabcb653fe53f82c1db61ca2c967f Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 13 May 2026 13:24:50 +0200 Subject: [PATCH 3/5] Additional values plus demo --- .idea/vcs.xml | 4 ++ .../mpp/demo/HapticFeedbackExample.web.kt | 65 +++++++++++++++++++ .../androidx/compose/mpp/demo/Main.web.kt | 3 +- .../ui/platform/DefaultHapticFeedback.web.kt | 58 +++++++---------- .../ui/window/ComposeWindowInternal.web.kt | 14 +++- .../ui/platform/WebHapticFeedbackTest.kt | 50 -------------- 6 files changed, 108 insertions(+), 86 deletions(-) create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/HapticFeedbackExample.web.kt delete mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt diff --git a/.idea/vcs.xml b/.idea/vcs.xml index a57e354d43e5a..4c88b42e6d9b6 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -16,5 +16,9 @@ + + + + \ No newline at end of file diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/HapticFeedbackExample.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/HapticFeedbackExample.web.kt new file mode 100644 index 0000000000000..4822165065428 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/HapticFeedbackExample.web.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 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 androidx.compose.mpp.demo + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp + +private val haptics = listOf( + "Confirm" to HapticFeedbackType.Confirm, + "Reject" to HapticFeedbackType.Reject, + + "LongPress" to HapticFeedbackType.LongPress, + "ContextClick" to HapticFeedbackType.ContextClick, + + "TextHandleMove" to HapticFeedbackType.TextHandleMove, + "SegmentTick" to HapticFeedbackType.SegmentTick, + "SegmentFrequentTick" to HapticFeedbackType.SegmentFrequentTick, + + "GestureEnd" to HapticFeedbackType.GestureEnd, + "GestureThresholdActivate" to HapticFeedbackType.GestureThresholdActivate, + "ToggleOff" to HapticFeedbackType.ToggleOff, + "ToggleOn" to HapticFeedbackType.ToggleOn, + "VirtualKey" to HapticFeedbackType.VirtualKey, +) + +val HapticFeedbackExample = Screen.Example("Haptic feedback") { + + val feedback = LocalHapticFeedback.current + + LazyColumn( + contentPadding = PaddingValues(16.dp) + ) { + items(haptics) { + Button(onClick = { + feedback.performHapticFeedback(it.second) + }) { + Text(it.first) + } + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 530f84d25740b..c5b0cf2d15ed6 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -50,7 +50,8 @@ fun main() { Screen.Example("Web Clipboard API example") { WebClipboardDemo() }, - HtmlInteropDemos + HtmlInteropDemos, + HapticFeedbackExample, ) ) } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt index 94cfecbaa593e..25519f437a3d0 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -27,40 +27,42 @@ import kotlin.js.toJsNumber @OptIn(ExperimentalWasmJsInterop::class) internal class WebHapticFeedback : HapticFeedback { - // Check if API is supported before doing anything - private val isVibrationSupported = isVibrationSupported() - // Declare these hardcoded patterns to avoid js-interop on every call - private val ConfirmVibrationPattern: JsArray = vibrationPatternOf(18, 32, 36) - private val RejectVibrationPattern: JsArray = vibrationPatternOf(18, 28, 18, 28, 18) - private val SinglePulseVibrationPattern: JsArray = vibrationPatternOf(12) - private val SoftTickVibrationPattern: JsArray = vibrationPatternOf(6) + // on Android these values are configured + // see config_longPressVibePattern in https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/res/values/config.xml + // We don't have a high-level browser API right now. So we hardcode the patterns here. + // TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics + private companion object { + val ConfirmVibrationPattern = vibrationPatternOf(18, 32, 36) + val RejectVibrationPattern = vibrationPatternOf(18, 28, 18, 28, 18) + val SinglePulseVibrationPattern = vibrationPatternOf(12) + val SoftTickVibrationPattern = vibrationPatternOf(6) + val LongPressVibrationPattern = vibrationPatternOf(0, 30) + val VirtualKeyVibrationPattern = vibrationPatternOf(0, 20) + } override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { - if (!isVibrationSupported) return val pattern = vibrationPatternFor(hapticFeedbackType) ?: return vibrate(pattern) } - // We don't have a high-level browser API right now. So we hardcode the patterns here. - // TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics - // and rely on it once it's implemented + @OptIn(ExperimentalWasmJsInterop::class) - internal fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray? { + private fun vibrationPatternFor(hapticFeedbackType: HapticFeedbackType): JsArray? { return when (hapticFeedbackType) { HapticFeedbackType.Confirm -> ConfirmVibrationPattern + HapticFeedbackType.ContextClick -> SinglePulseVibrationPattern + HapticFeedbackType.GestureEnd -> SinglePulseVibrationPattern + HapticFeedbackType.GestureThresholdActivate -> SinglePulseVibrationPattern + HapticFeedbackType.KeyboardTap -> SoftTickVibrationPattern + HapticFeedbackType.LongPress -> LongPressVibrationPattern HapticFeedbackType.Reject -> RejectVibrationPattern - HapticFeedbackType.ContextClick, - HapticFeedbackType.GestureEnd, - HapticFeedbackType.GestureThresholdActivate, - HapticFeedbackType.LongPress, - HapticFeedbackType.ToggleOff, - HapticFeedbackType.ToggleOn, - HapticFeedbackType.VirtualKey -> SinglePulseVibrationPattern - HapticFeedbackType.KeyboardTap, - HapticFeedbackType.SegmentFrequentTick, + HapticFeedbackType.SegmentFrequentTick -> SoftTickVibrationPattern HapticFeedbackType.SegmentTick -> SoftTickVibrationPattern - HapticFeedbackType.TextHandleMove -> null + HapticFeedbackType.TextHandleMove -> ConfirmVibrationPattern + HapticFeedbackType.ToggleOff -> SinglePulseVibrationPattern + HapticFeedbackType.ToggleOn -> SinglePulseVibrationPattern + HapticFeedbackType.VirtualKey -> VirtualKeyVibrationPattern else -> null } } @@ -68,17 +70,7 @@ internal class WebHapticFeedback : HapticFeedback { @OptIn(ExperimentalWasmJsInterop::class) private fun vibrationPatternOf(vararg durations: Int): JsArray = - durations.map { it.toDouble().toJsNumber() }.toJsArray() - -@OptIn(ExperimentalWasmJsInterop::class) -private fun isVibrationSupported(): Boolean = js( - //language=javascript - """ - typeof window !== 'undefined' && - window.navigator != null && - typeof window.navigator.vibrate === 'function' - """ -) + durations.map { it.toJsNumber() }.toJsArray() //language=javascript @OptIn(ExperimentalWasmJsInterop::class) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 3ca31b3da921e..567634b6d1f64 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.input.pointer.composeButtons import androidx.compose.ui.internal.focusExt import androidx.compose.ui.navigationevent.BackNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner +import androidx.compose.ui.platform.DefaultHapticFeedback import androidx.compose.ui.platform.DefaultInputModeManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.PlatformContext @@ -206,7 +207,7 @@ internal class ComposeWindow( @VisibleForTesting internal val archComponentsOwner = DefaultArchitectureComponentsOwner() - private val hapticFeedback = WebHapticFeedback() + private val hapticFeedback = if (isVibrationSupported()) WebHapticFeedback() else DefaultHapticFeedback() private val navigationEventInput = BackNavigationEventInput() @@ -876,4 +877,13 @@ private fun Element.isFocused(): Boolean { private external interface ShadowRootExt { val activeElement: Element? -} \ No newline at end of file +} + +private fun isVibrationSupported(): Boolean = js( + //language=javascript + """ + typeof window !== 'undefined' && + window.navigator != null && + typeof window.navigator.vibrate === 'function' + """ +) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt deleted file mode 100644 index a6dc0767e6aa9..0000000000000 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebHapticFeedbackTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) - -/* - * Copyright 2026 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 androidx.compose.ui.platform - -import androidx.compose.ui.OnCanvasTests -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class WebHapticFeedbackTest : OnCanvasTests { - - @Test - fun composeWindowProvidesWebHapticFeedback() = runApplicationTest { - var hapticFeedback: WebHapticFeedback? = null - - createComposeWindow { - hapticFeedback = LocalHapticFeedback.current as? WebHapticFeedback - } - - assertNotNull(hapticFeedback, "LocalHapticFeedback should provide WebHapticFeedback") - val pattern = hapticFeedback!!.vibrationPatternFor(HapticFeedbackType.Confirm) - - assertNotNull(pattern, "pattern should not be null") - - // We can't verify the vibration has been performed, - // so just call performHapticFeedback to check that it doesn't fail - hapticFeedback!!.performHapticFeedback(HapticFeedbackType.Confirm) - } -} \ No newline at end of file From 022c7c3fad5c44bd8b02e7d7012d9435d28eddab Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 13 May 2026 13:46:53 +0200 Subject: [PATCH 4/5] evert changes accidentally commited --- .idea/vcs.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 4c88b42e6d9b6..a57e354d43e5a 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -16,9 +16,5 @@ - - - - \ No newline at end of file From 3e2e9ef97599cb4e72cb6f0bb801f89755649f3b Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 13 May 2026 13:53:21 +0200 Subject: [PATCH 5/5] Introduce webHapticFeedbackOrDefault helper function --- .../ui/platform/DefaultHapticFeedback.web.kt | 27 +++++++++++++------ .../ui/window/ComposeWindowInternal.web.kt | 10 +------ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt index 25519f437a3d0..aebc0fc0100ff 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -32,13 +32,15 @@ internal class WebHapticFeedback : HapticFeedback { // see config_longPressVibePattern in https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/res/values/config.xml // We don't have a high-level browser API right now. So we hardcode the patterns here. // TODO: to eventually avoid the hardcoded values, follow the new browser API proposal https://github.com/WICG/web-haptics - private companion object { - val ConfirmVibrationPattern = vibrationPatternOf(18, 32, 36) - val RejectVibrationPattern = vibrationPatternOf(18, 28, 18, 28, 18) - val SinglePulseVibrationPattern = vibrationPatternOf(12) - val SoftTickVibrationPattern = vibrationPatternOf(6) - val LongPressVibrationPattern = vibrationPatternOf(0, 30) - val VirtualKeyVibrationPattern = vibrationPatternOf(0, 20) + companion object { + private val ConfirmVibrationPattern = vibrationPatternOf(18, 32, 36) + private val RejectVibrationPattern = vibrationPatternOf(18, 28, 18, 28, 18) + private val SinglePulseVibrationPattern = vibrationPatternOf(12) + private val SoftTickVibrationPattern = vibrationPatternOf(6) + private val LongPressVibrationPattern = vibrationPatternOf(0, 30) + private val VirtualKeyVibrationPattern = vibrationPatternOf(0, 20) + + fun webHapticFeedbackOrDefault(): HapticFeedback = if (isVibrationSupported()) WebHapticFeedback() else DefaultHapticFeedback() } override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { @@ -77,4 +79,13 @@ private fun vibrationPatternOf(vararg durations: Int): JsArray = private fun vibrate(pattern: JsArray) { // Assuming the API support has been checked in advance, we can safely call it js("window.navigator.vibrate(pattern)") -} \ No newline at end of file +} + +private fun isVibrationSupported(): Boolean = js( + //language=javascript + """ + typeof window !== 'undefined' && + window.navigator != null && + typeof window.navigator.vibrate === 'function' + """ +) \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 567634b6d1f64..cb2d463874a2b 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -207,7 +207,7 @@ internal class ComposeWindow( @VisibleForTesting internal val archComponentsOwner = DefaultArchitectureComponentsOwner() - private val hapticFeedback = if (isVibrationSupported()) WebHapticFeedback() else DefaultHapticFeedback() + private val hapticFeedback = WebHapticFeedback.webHapticFeedbackOrDefault() private val navigationEventInput = BackNavigationEventInput() @@ -879,11 +879,3 @@ private external interface ShadowRootExt { val activeElement: Element? } -private fun isVibrationSupported(): Boolean = js( - //language=javascript - """ - typeof window !== 'undefined' && - window.navigator != null && - typeof window.navigator.vibrate === 'function' - """ -)