Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* 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.
*/

@file:OptIn(ExperimentalWasmJsInterop::class)

package androidx.compose.ui.platform

import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.events.EventTargetListener
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlin.js.ExperimentalWasmJsInterop
import kotlin.js.js
import kotlinx.browser.window
import org.w3c.dom.DOMRect
import org.w3c.dom.Element
import org.w3c.dom.events.EventTarget

private class WebWindowInsets(
private val safeArea: () -> PlatformInsets,
private val keyboard: () -> PlatformInsets,
) : PlatformWindowInsets {
override val statusBars: PlatformInsets
get() = PlatformInsets(getTop = { safeArea().top })
override val navigationBars: PlatformInsets
get() = PlatformInsets(getBottom = { safeArea().bottom })
override val systemBars: PlatformInsets
get() = safeArea()
override val displayCutout: PlatformInsets
get() = safeArea()
override val ime: PlatformInsets
get() = keyboard()
override val systemGestures: PlatformInsets
get() = safeArea()
override val mandatorySystemGestures: PlatformInsets
get() = PlatformInsets(getTop = { safeArea().top }, getBottom = { safeArea().bottom })
override val tappableElement: PlatformInsets
get() = PlatformInsets(getTop = { safeArea().top })
}

/**
* Reads system window insets (safe area and IME) from the browser and exposes them as Compose
* state.
*
* Safe area insets are read from CSS `env(safe-area-inset-*)` environment variables via CSS custom
* properties, and re-read on each window resize event.
*
* IME (virtual keyboard) insets are tracked using:
* - **VirtualKeyboard API** when available — the most precise source.
* - **VisualViewport API** as a fallback for Safari and Firefox — derived from the difference
* between `window.innerHeight` and `visualViewport.height`.
*
* All insets are clipped to the portion of the system UI zones that the [composeScene] actually
* overlaps. For example, if the canvas is positioned below the status bar, the top inset will be
* zero; if it extends into the navigation bar area, the bottom inset will reflect the overlap.
*
*/
internal class WebWindowInsetsManager(
private val density: Density,
canvas: Element
) {
private var canvasRect: DOMRect = canvas.getBoundingClientRect()
set(value) {
field = value
readAndUpdateSafeArea()
readAndUpdateIme()
}

private val safeAreaInsets = mutableStateOf(PlatformInsets.Zero)
private val imeInsets = mutableStateOf(PlatformInsets.Zero)

val windowInsets: PlatformWindowInsets = WebWindowInsets(
safeArea = { safeAreaInsets.value },
keyboard = { imeInsets.value },
)

private val hasVirtualKeyboardApi: Boolean = hasVirtualKeyboard()

private val imeEventsListener: EventTargetListener?

init {
installSafeAreaCssProperties()
imeEventsListener = initImeTracking()
}

fun dispose() {
imeEventsListener?.dispose()
}

fun onCanvasResized(canvas: Element) {
canvasRect = canvas.getBoundingClientRect()
}

private fun initImeTracking(): EventTargetListener? {
return if (hasVirtualKeyboardApi) {
enableVirtualKeyboardOverlay()
val vk = getVirtualKeyboard() ?: return null
EventTargetListener(vk).apply {
addDisposableEvent("geometrychange") { readAndUpdateIme() }
}
} else {
val vv = getVisualViewport() ?: return null
EventTargetListener(vv).apply {
addDisposableEvent("resize") { readAndUpdateIme() }
}
}
}

private fun readAndUpdateSafeArea() {
val vw = window.innerWidth.toFloat()
val vh = window.innerHeight.toFloat()
val adjustedLeft = maxOf(0f, readCssVarLeft() - canvasRect.left.toFloat())
val adjustedTop = maxOf(0f, readCssVarTop() - canvasRect.top.toFloat())
val adjustedRight = maxOf(0f, readCssVarRight() - (vw - canvasRect.right.toFloat()))
val adjustedBottom = maxOf(0f, readCssVarBottom() - (vh - canvasRect.bottom.toFloat()))

safeAreaInsets.value = with(density) {
PlatformInsets(
left = adjustedLeft.dp.roundToPx(),
top = adjustedTop.dp.roundToPx(),
right = adjustedRight.dp.roundToPx(),
bottom = adjustedBottom.dp.roundToPx()
)
}
}

private fun readAndUpdateIme() {
val rawHeight = if (hasVirtualKeyboardApi) {
readVirtualKeyboardHeight()
} else {
readVisualViewportImeHeight()
}
val vh = window.innerHeight.toFloat()
val adjustedBottom = maxOf(0f, rawHeight - (vh - canvasRect.bottom.toFloat()))

imeInsets.value = with(density) {
PlatformInsets(bottom = adjustedBottom.dp.roundToPx())
}
}
}

/**
* Installs CSS custom properties on `document.documentElement` that mirror `env(safe-area-inset-*)`.
*
* Setting them on the root element (rather than inside a canvas shadow root) works around a WebKit
* bug where `env()` values return 0 in canvas-based shadow roots on some iOS versions.
*/
// language=js
private fun installSafeAreaCssProperties(): Unit = js(
"""(function() {
let s = document.documentElement.style;
s.setProperty('--cmp-safe-top', 'env(safe-area-inset-top, 0px)');
s.setProperty('--cmp-safe-right', 'env(safe-area-inset-right, 0px)');
s.setProperty('--cmp-safe-bottom', 'env(safe-area-inset-bottom, 0px)');
s.setProperty('--cmp-safe-left', 'env(safe-area-inset-left, 0px)');
})()"""
)

// language=js
private fun readCssVarTop(): Float =
js("(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cmp-safe-top')) || 0)")

// language=js
private fun readCssVarRight(): Float =
js("(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cmp-safe-right')) || 0)")

// language=js
private fun readCssVarBottom(): Float =
js("(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cmp-safe-bottom')) || 0)")

// language=js
private fun readCssVarLeft(): Float =
js("(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cmp-safe-left')) || 0)")

// language=js
private fun hasVirtualKeyboard(): Boolean = js("('virtualKeyboard' in navigator)")

// language=js
private fun enableVirtualKeyboardOverlay(): Unit =
js("(navigator.virtualKeyboard.overlaysContent = true)")
Comment thread
terrakok marked this conversation as resolved.

// language=js
private fun getVirtualKeyboard(): EventTarget? = js("navigator.virtualKeyboard")

/** Returns the current keyboard height in CSS pixels (0 when keyboard is hidden). */
// language=js
private fun readVirtualKeyboardHeight(): Float =
js("(navigator.virtualKeyboard.boundingRect.height || 0)")

// --- IME: VisualViewport API fallback (Safari, Firefox) ---

// language=js
private fun getVisualViewport(): EventTarget? = js("(window.visualViewport || null)")

/**
* Estimates the IME height in CSS pixels from the visual viewport geometry.
* Returns 0 when the keyboard is not visible.
*/
// language=js
private fun readVisualViewportImeHeight(): Float = js("""(function() {
let vv = window.visualViewport;
if (!vv) return 0;
return Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
})()""")
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,33 @@ class ComposeViewportConfiguration internal constructor() {
*/
@ExperimentalComposeUiApi
var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled

/**
* Controls whether the Compose scene handles system window insets (status bar, navigation bar,
* IME keyboard) and exposes them via [androidx.compose.foundation.layout.WindowInsets] APIs
* such as `WindowInsets.safeDrawing`, `WindowInsets.ime`, etc.
*
* When set to `true`, the scene reads safe area insets from the browser using CSS
* `env(safe-area-inset-*)` environment variables, and tracks IME (virtual keyboard) geometry.
*
* **Prerequisite**: the page must opt in to edge-to-edge rendering by including
* `viewport-fit=cover` in the viewport meta tag:
* ```html
* <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
* ```
* Without `viewport-fit=cover`, the browser applies safe area padding automatically and all
* `env(safe-area-inset-*)` variables return `0px`, so insets will always be zero.
*
* By default, this is `false` and the scene reports zero insets.
*
* **Scrollable containers:** insets are re-read on `window resize` and keyboard geometry events,
* but not on page scroll. If the [composeScene] is inside a scrollable page, its viewport position
* changes as the user scrolls, so the insets may become invalid. In that case
* it is recommended to disable inset handling entirely (`enableBrowserWindowInsets = false`) and
* manage padding manually.
*
* Note: This API is experimental and subject to change in the future.
*/
@ExperimentalComposeUiApi
var enableBrowserWindowInsets: Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

@file:OptIn(ExperimentalWasmJsInterop::class)
@file:OptIn(ExperimentalWasmJsInterop::class, ExperimentalWasmJsInterop::class)
Comment thread
terrakok marked this conversation as resolved.
Outdated

package androidx.compose.ui.window

Expand Down Expand Up @@ -53,6 +53,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.EmptyPlatformWindowInsets
import androidx.compose.ui.platform.PlatformContext
import androidx.compose.ui.platform.PlatformDragAndDropManager
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
Expand All @@ -62,6 +63,7 @@ import androidx.compose.ui.platform.WebHapticFeedback
import androidx.compose.ui.platform.WebTextInputService
import androidx.compose.ui.platform.WebTextToolbar
import androidx.compose.ui.platform.WebWakeLockManager
import androidx.compose.ui.platform.WebWindowInsetsManager
import androidx.compose.ui.platform.WindowInfoImpl
import androidx.compose.ui.platform.accessibility.ComposeWebSemanticsListener
import androidx.compose.ui.platform.installFallbackFontDownloader
Expand All @@ -88,6 +90,8 @@ import androidx.compose.ui.viewinterop.TrackInteropPlacementContainer
import androidx.compose.ui.viewinterop.WebInteropContainer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.enableSavedStateHandles
import kotlin.js.ExperimentalWasmJsInterop
import kotlin.js.js
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -212,6 +216,8 @@ internal class ComposeWindow(

private val canvasEvents = EventTargetListener(canvas)

private var insetsManager: WebWindowInsetsManager? = null

private var keyboardModeState: KeyboardModeState = KeyboardModeState.Hardware

// Used in WebTextInputService. Also see https://youtrack.jetbrains.com/issue/CMP-8611
Expand All @@ -235,6 +241,7 @@ internal class ComposeWindow(
object : PlatformContext by PlatformContext.Empty() {
override val windowInfo get() = _windowInfo
override val architectureComponentsOwner get() = archComponentsOwner
override val windowInsets get() = insetsManager?.windowInsets ?: EmptyPlatformWindowInsets

override val dragAndDropManager: PlatformDragAndDropManager = object :
WebDragAndDropManager(rootNode, canvasEvents, state.globalEvents, density) {
Expand Down Expand Up @@ -473,6 +480,11 @@ internal class ComposeWindow(
}

init {
if (configuration.enableBrowserWindowInsets) {
checkViewportFitCover()
insetsManager = WebWindowInsetsManager(density, canvas)
}

initEvents(canvas)
state.init()

Expand Down Expand Up @@ -546,6 +558,8 @@ internal class ComposeWindow(
skiaLayer.attachTo(canvas)
scene.size = sizeInPx
skiaLayer.needRender()

insetsManager?.onCanvasResized(canvas)
}

// TODO: need to call .dispose() on window close.
Expand All @@ -560,6 +574,7 @@ internal class ComposeWindow(
frameRecomposer.close()
skiaLayer.detach()

insetsManager?.dispose()
systemThemeObserver.dispose()
state.dispose()
// modern browsers supposed to garbage collect all events on the element disposed
Expand Down Expand Up @@ -875,6 +890,22 @@ private fun clipTargetElement(canvas: HTMLCanvasElement): HTMLTextAreaElement {
return clipTarget
}

// language=js
private fun checkViewportFitCover(): Unit = js(
"""(function() {
let meta = document.querySelector('meta[name=viewport]');
let content = meta ? (meta.getAttribute('content') || '') : '';
if (!content.includes('viewport-fit=cover')) {
console.warn(
"[ComposeWeb] enableBrowserWindowInsets is set to true, but " +
"'viewport-fit=cover' is not found in the viewport meta tag. " +
"Safe area insets will be zero. Add viewport-fit=cover to your viewport meta tag: " +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">"
);
}
})()"""
)

// strings checks are faster on a JS side
// language=js
private fun isTouchEvent(event: PointerEvent): Boolean = js("event.pointerType === 'touch'")
Expand Down
Loading
Loading