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 c5b0cf2d15ed6..e8e7fd3f4db50 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 @@ -20,6 +20,7 @@ import androidx.compose.mpp.demo.bugs.BugsScreen import androidx.compose.mpp.demo.components.text.loadResource import androidx.compose.mpp.demo.interops.HtmlInteropDemos import androidx.compose.runtime.LaunchedEffect +import androidx.compose.mpp.demo.embedded.embeddedScrollDemo import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi @@ -30,16 +31,34 @@ import androidx.compose.ui.window.ComposeViewport import androidx.navigation.ExperimentalBrowserHistoryApi import androidx.navigation.bindToBrowserNavigation import androidx.navigation.compose.rememberNavController -import kotlin.js.ExperimentalJsReflectionCreateInstance +import kotlinx.browser.window import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.decodeFromString -@OptIn(ExperimentalComposeUiApi::class) -@ExperimentalBrowserHistoryApi +private fun queryParams(): Map { + return window.location.search + .removePrefix("?") + .split("&") + .filter { it.contains("=") } + .associate { val (k, v) = it.split("=", limit = 2); k to v } +} + fun main() { + val demo = queryParams()["demo"] ?: "default" + when (demo) { + "default" -> defaultComposeDemo() + "embedded" -> embeddedScrollDemo(composeScroll = false) + "embeddedWithScroll" -> embeddedScrollDemo(composeScroll = true) + } +} + +@OptIn( + ExperimentalComposeUiApi::class, + ExperimentalBrowserHistoryApi::class +) +fun defaultComposeDemo() { ComposeViewport(viewportContainerId = "composeApplication") { val navController = rememberNavController() val fontFamilyResolver = LocalFontFamilyResolver.current @@ -84,6 +103,5 @@ fun main() { } } - @Serializable private data class FontsManifest(val fonts: List) \ No newline at end of file diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/PointerInputDebugOverlay.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/PointerInputDebugOverlay.kt index 2aaa2d1d8b2fb..5c8089502d766 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/PointerInputDebugOverlay.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/PointerInputDebugOverlay.kt @@ -74,6 +74,7 @@ fun PointerInputDebugOverlay( logString("- $idValue") pointers.value -= idValue } + change.consume() } } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/embedded/EmbeddedComposeViewport.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/embedded/EmbeddedComposeViewport.kt new file mode 100644 index 0000000000000..7a1cf6f416810 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/embedded/EmbeddedComposeViewport.kt @@ -0,0 +1,140 @@ +package androidx.compose.mpp.demo.embedded + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.ComposeViewport +import kotlinx.browser.document +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLElement + +@OptIn(ExperimentalComposeUiApi::class) +// A reproducer for https://youtrack.jetbrains.com/issue/CMP-10351 +fun embeddedScrollDemo(composeScroll: Boolean = true) { + // Override the default fullscreen styles to allow page scrolling + val style = document.createElement("style") + style.textContent = """ + html, body { + width: 100%; + height: auto !important; + margin: 0; + padding: 0; + overflow: auto !important; + } + body { + display: block !important; + } + #composeApplication { + display: none; + } + """.trimIndent() + document.head?.appendChild(style) + + val body = document.body ?: return + + // Title + val heading = document.createElement("h2") + heading.textContent = "Embedded ComposeViewport Scroll Demo" + (heading as HTMLElement).style.padding = "16px" + body.appendChild(heading) + + val description = document.createElement("p") + description.textContent = "This demo shows a ComposeViewport embedded in a scrollable HTML page. " + + "Scroll the page to see Compose co-operating with native HTML scroll gestures." + + if (!composeScroll) " Compose content has no internal scroll." else "" + (description as HTMLElement).style.apply { + padding = "0 16px" + fontStyle = "italic" + color = "#777" + } + body.appendChild(description) + + // HTML content before Compose + addHtmlParagraphs(body, 5, "Above Compose —") + + // Compose container + val composeContainer = document.createElement("div") as HTMLDivElement + composeContainer.style.apply { + width = "100%" + height = "400px" + borderTop = "2px solid #1976D2" + borderBottom = "2px solid #1976D2" + margin = "16px 0" + } + body.appendChild(composeContainer) + + // HTML content after Compose + addHtmlParagraphs(body, 10, "Below Compose —") + + ComposeViewport(composeContainer) { + MaterialTheme { + var counter by remember { mutableStateOf(0) } + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Compose content inside HTML scroll", fontSize = 18.sp) + Button(onClick = { counter++ }) { + Text("Clicked $counter times") + } + } + } + + if (composeScroll) { + ComposeScrollableContent() + } + } + } + } +} + +@Composable +fun ComposeScrollableContent() { + LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + items(30) { index -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .border(1.dp, Color.LightGray) + .padding(12.dp) + ) { + Text("Compose LazyColumn item #$index") + } + } + } +} + +private fun addHtmlParagraphs(container: org.w3c.dom.Element, count: Int, prefix: String) { + for (i in 1..count) { + val p = document.createElement("p") + p.textContent = "$prefix paragraph $i: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris." + (p as HTMLElement).style.apply { + padding = "0 16px" + lineHeight = "1.6" + } + container.appendChild(p) + } +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index 9b7cf83e81cb7..79ec7a56d1bd5 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -160,7 +160,7 @@ fun ComposeViewport( canvas.setAttribute("tabindex", "0") canvas.setAttribute("role", "generic") canvas.style.outline = "none" // Fixes https://youtrack.jetbrains.com/issue/CMP-9040 - canvas.style.setProperty("touch-action", "none") //blocks default browser touch handling + canvas.style.setProperty("touch-action", "pan-x pan-y") // allow the browser to scroll when compose is not scrolling appContainer.appendChild(canvas) //a11y container 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 18b310c246197..2700d66fc497f 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 @@ -20,6 +20,7 @@ package androidx.compose.ui.window import androidx.annotation.VisibleForTesting import androidx.collection.mutableIntObjectMapOf +import androidx.collection.mutableIntSetOf import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.InternalComposeApi @@ -91,7 +92,6 @@ import androidx.lifecycle.enableSavedStateHandles import kotlinx.browser.document import kotlinx.browser.window import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.coroutineScope @@ -311,7 +311,7 @@ internal class ComposeWindow( override val viewConfiguration = object : ViewConfiguration by PlatformContext.DefaultViewConfiguration { - override val touchSlop: Float get() = with(density) { 18.dp.toPx() } + override val touchSlop: Float get() = with(density) { 8.dp.toPx() } override val maximumFlingVelocity: Float //https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/core/java/android/view/ViewConfiguration.java;l=240;drc=733537294b158d22f2ae383f2ed77c93741798e9 get() = with(density) { 8000.dp.toPx() } @@ -402,6 +402,10 @@ internal class ComposeWindow( } } + // It helps Compose to co-operate with the browser's scroll when the ComposeViewport + // is nested in a scrollable html container + private val rootScrollObserver = RootScrollObserver() + private fun initEvents(canvas: HTMLCanvasElement) { listOf( @@ -432,6 +436,23 @@ internal class ComposeWindow( } } + // While we don't pass touchmove(s) to Compose, we need to track them to prevent the browser from taking over the gestures. + // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action + // > Applications using Touch events disable the browser handling of gestures by calling preventDefault() + addTypedEvent("touchmove") { evt -> + // This event happens after pointermove. + if (rootScrollObserver.consumedAnyScroll()) { + // By calling preventDefault here we prevent a browser from overtaking a scroll gesture. + evt.preventDefault() + } else if (!rootScrollObserver.hadAnyScroll() && !activeTouchPointersConsumedMoves.isEmpty()) { + // The pointermove(s) didn't hit any Compose scrollable content, but + // we registered at least 1 pointermove event for one or more currently active pointers, + // and that pointermove was consumed by Compose. + // So here we prevent the browser from taking over the gestures. + evt.preventDefault() + } + } + addTypedEvent("wheel", passive = false) { event -> onWheelEvent(event) } @@ -496,8 +517,10 @@ internal class ComposeWindow( LocalComposeWindow provides this, content = { installFallbackFontDownloader() - interopContainer.TrackInteropPlacementContainer { - content() + WithNestedScrollObserver(rootScrollObserver) { + interopContainer.TrackInteropPlacementContainer { + content() + } } LaunchedEffect(Unit) { @@ -594,6 +617,7 @@ internal class ComposeWindow( } private val activeTouchPointers = mutableIntObjectMapOf() + private val activeTouchPointersConsumedMoves = mutableIntSetOf() private val reusableTouchPointerList = mutableListOf() private fun getActivePointers(): MutableList { reusableTouchPointerList.clear() @@ -607,6 +631,7 @@ internal class ComposeWindow( if (event.type == "pointercancel") { if (isTouchEvent(event)) { activeTouchPointers.clear() + activeTouchPointersConsumedMoves.remove(event.pointerId) activeTouchOffset = null } else { actualActivePointerButtons = null @@ -620,6 +645,7 @@ internal class ComposeWindow( val eventType = event.getPointerEventType() var result: PointerEventResult? = null + var anyChangeConsumed = false if (isMouseEvent(event)) { keyboardModeState = KeyboardModeState.Hardware @@ -656,6 +682,10 @@ internal class ComposeWindow( return } + if (activeTouchPointers.isEmpty()) { + rootScrollObserver.reset() + } + // iOS Safari doesn't request focus when the page is shown, // and the lifecycle doesn't trigger ON_RESUME. // so, we decided to handle every touch @@ -714,6 +744,7 @@ internal class ComposeWindow( nativeEvent = coalescedEvent, button = null ) + anyChangeConsumed = anyChangeConsumed || result.anyChangeConsumed } } else { result = scene.sendPointerEvent( @@ -726,16 +757,21 @@ internal class ComposeWindow( nativeEvent = event, button = null ) + anyChangeConsumed = result.anyChangeConsumed } activeTouchOffset = null if (eventType == PointerEventType.Release) { activeTouchPointers.remove(event.pointerId) + activeTouchPointersConsumedMoves.remove(event.pointerId) } - if (result != null && result.anyChangeConsumed && event.cancelable) { + if (anyChangeConsumed && event.cancelable) { event.preventDefault() + if (eventType == PointerEventType.Move) { + activeTouchPointersConsumedMoves.add(event.pointerId) + } } } } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/RootScrollObserver.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/RootScrollObserver.kt new file mode 100644 index 0000000000000..5d2fa70abcce7 --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/RootScrollObserver.kt @@ -0,0 +1,81 @@ +/* + * 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.window + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxOfOrNull + +internal class RootScrollObserver : NestedScrollConnection { + private var consumedDistance = 0f + private var totalScrollEvents = 0 + + // Reset when all pointers are up. + fun reset() { + consumedDistance = 0f + totalScrollEvents = 0 + } + + fun consumedAnyScroll() = totalScrollEvents > 0 && consumedDistance > 0f + fun hadAnyScroll() = totalScrollEvents > 0 + + // Descendants call dispatchPreScroll BEFORE trying to scroll themselves. + // We don't want to steal anything — return Zero. + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return Offset.Zero + } + + // Descendants call dispatchPostScroll AFTER they've scrolled. + // `consumed` is the total already consumed by the chain below us. + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // Only care about drag-driven scrolls, not fling/programmatic. + if (source == NestedScrollSource.UserInput) { + totalScrollEvents++ + consumedDistance += consumed.getDistanceSquared() + } + return Offset.Zero + } +} + +@Composable +internal fun WithNestedScrollObserver( + rootScrollObserver: RootScrollObserver, + content: @Composable () -> Unit +) { + Layout( + modifier = Modifier.nestedScroll(rootScrollObserver), + content = content + ) { measurables, constraints -> + val placeables = measurables.fastMap { it.measure(constraints) } + val w = placeables.fastMaxOfOrNull { it.width } ?: 0 + val h = placeables.fastMaxOfOrNull { it.height } ?: 0 + layout(w, h) { + placeables.fastForEach { it.place(0, 0) } + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/GesturesTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/GesturesTest.kt index 33e688f8f802b..ac942053d8c0a 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/GesturesTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/GesturesTest.kt @@ -62,9 +62,10 @@ class GesturesTest : OnCanvasTests { ) val actualPan = 10f * currentDensity.density - assertEquals(2, pans.size) - assertEquals(Offset(0f, actualPan), pans[0]) - assertEquals(Offset(actualPan, 0f), pans[1]) + assertEquals(3, pans.size) + assertEquals(Offset(actualPan, actualPan), pans[0]) + assertEquals(Offset(0f, actualPan), pans[1]) + assertEquals(Offset(actualPan, 0f), pans[2]) } @Test @@ -103,10 +104,10 @@ class GesturesTest : OnCanvasTests { ) // Verify that at least one zoom value greater than 1.0 was recorded. - assertEquals(7, zooms.size) + assertEquals(9, zooms.size) println(zooms.joinToString(",")) - assertTrue(zooms[0] > 1 && zooms[0] < zooms[2]) // according to the Offset change - assertTrue(zooms[3] < 1 && zooms[3] < zooms[5]) // according to the Offset change + assertTrue(zooms[2] > 1 && zooms[2] < zooms[4]) // according to the Offset change + assertTrue(zooms[5] < 1 && zooms[5] < zooms[7]) // according to the Offset change } @Test