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 new file mode 100644 index 0000000000000..aebc0fc0100ff --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DefaultHapticFeedback.web.kt @@ -0,0 +1,91 @@ +/* + * 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 + +@OptIn(ExperimentalWasmJsInterop::class) +internal class WebHapticFeedback : HapticFeedback { + + // 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 + 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) { + val pattern = vibrationPatternFor(hapticFeedbackType) ?: return + vibrate(pattern) + } + + + @OptIn(ExperimentalWasmJsInterop::class) + 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.SegmentFrequentTick -> SoftTickVibrationPattern + HapticFeedbackType.SegmentTick -> SoftTickVibrationPattern + HapticFeedbackType.TextHandleMove -> ConfirmVibrationPattern + HapticFeedbackType.ToggleOff -> SinglePulseVibrationPattern + HapticFeedbackType.ToggleOn -> SinglePulseVibrationPattern + HapticFeedbackType.VirtualKey -> VirtualKeyVibrationPattern + else -> null + } + } +} + +@OptIn(ExperimentalWasmJsInterop::class) +private fun vibrationPatternOf(vararg durations: Int): JsArray = + durations.map { it.toJsNumber() }.toJsArray() + +//language=javascript +@OptIn(ExperimentalWasmJsInterop::class) +private fun vibrate(pattern: JsArray) { + // Assuming the API support has been checked in advance, we can safely call it + js("window.navigator.vibrate(pattern)") +} + +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 8e6679c5bf89f..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 @@ -52,12 +52,15 @@ 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 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 +207,8 @@ internal class ComposeWindow( @VisibleForTesting internal val archComponentsOwner = DefaultArchitectureComponentsOwner() + private val hapticFeedback = WebHapticFeedback.webHapticFeedbackOrDefault() + private val navigationEventInput = BackNavigationEventInput() private val canvasEvents = EventTargetListener(canvas) @@ -464,6 +469,7 @@ internal class ComposeWindow( scene.setContent { CompositionLocalProvider( LocalSystemTheme provides systemThemeObserver.currentSystemTheme.value, + LocalHapticFeedback provides hapticFeedback, LocalInteropContainer provides interopContainer, LocalActiveClipEventsTarget provides clipEventsTargetProvider, content = { @@ -871,4 +877,5 @@ private fun Element.isFocused(): Boolean { private external interface ShadowRootExt { val activeElement: Element? -} \ No newline at end of file +} +