From daa85a01c06a8625eeaf6c53b14bc7f9f03718dd Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 15 Oct 2025 16:01:14 +0200 Subject: [PATCH 1/4] add failing gesture test --- .../androidx/compose/ui/input/GesturesTest.kt | 138 ++++++++++++++++-- 1 file changed, 125 insertions(+), 13 deletions(-) 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 05a56127648d3..b0e5ae4befb98 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 @@ -26,6 +26,10 @@ import androidx.compose.ui.events.TouchEventInit import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density @@ -54,11 +58,11 @@ class GesturesTest : OnCanvasTests { } dispatchEvents( - TouchEvent("touchstart", touchEventInit(createTouch(0, getCanvas()))), + TouchEvent("touchstart", touchEventWithChangedTouchesInit(createTouch(0, getCanvas()))), // first move to exceed the touch slop - TouchEvent("touchmove", touchEventInit(createTouch(0, getCanvas(), clientX = 10.0, clientY = 10.0))), - TouchEvent("touchmove", touchEventInit(createTouch(0, getCanvas(), clientX = 10.0, clientY = 20.0))), - TouchEvent("touchmove", touchEventInit(createTouch(0, getCanvas(), clientX = 20.0, clientY = 20.0))), + TouchEvent("touchmove", touchEventWithChangedTouchesInit(createTouch(0, getCanvas(), clientX = 10.0, clientY = 10.0))), + TouchEvent("touchmove", touchEventWithChangedTouchesInit(createTouch(0, getCanvas(), clientX = 10.0, clientY = 20.0))), + TouchEvent("touchmove", touchEventWithChangedTouchesInit(createTouch(0, getCanvas(), clientX = 20.0, clientY = 20.0))), ) val actualPan = 10f * currentDensity.density @@ -87,7 +91,7 @@ class GesturesTest : OnCanvasTests { // Simulate two touch points starting fairly close together TouchEvent( "touchstart", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 50.0, clientY = 50.0), createTouch(1, getCanvas(), clientX = 60.0, clientY = 50.0) ) @@ -95,7 +99,7 @@ class GesturesTest : OnCanvasTests { // first move to exceed the touch slop TouchEvent( "touchmove", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 45.0, clientY = 60.0), createTouch(1, getCanvas(), clientX = 65.0, clientY = 50.0) ) @@ -103,14 +107,14 @@ class GesturesTest : OnCanvasTests { // Zoom in, zoom > 1 TouchEvent( "touchmove", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 40.0, clientY = 50.0), createTouch(1, getCanvas(), clientX = 70.0, clientY = 50.0) ) ), TouchEvent( "touchmove", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 30.0, clientY = 50.0), createTouch(1, getCanvas(), clientX = 80.0, clientY = 50.0) ) @@ -118,14 +122,14 @@ class GesturesTest : OnCanvasTests { // and now zoom out, zoom < 1 TouchEvent( "touchmove", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 35.0, clientY = 50.0), createTouch(1, getCanvas(), clientX = 75.0, clientY = 50.0) ) ), TouchEvent( "touchmove", - touchEventInit( + touchEventWithChangedTouchesInit( createTouch(0, getCanvas(), clientX = 37.0, clientY = 50.0), createTouch(1, getCanvas(), clientX = 73.0, clientY = 50.0) ) @@ -157,8 +161,8 @@ class GesturesTest : OnCanvasTests { assertNull(lastPointerEvent) dispatchEvents( - TouchEvent("touchstart", touchEventInit(createTouch(0, getCanvas(), clientX = 50.0, clientY = 50.0))), - TouchEvent("touchmove", touchEventInit(createTouch(0, getCanvas(), clientX = 60.0, clientY = 60.0))) + TouchEvent("touchstart", touchEventWithChangedTouchesInit(createTouch(0, getCanvas(), clientX = 50.0, clientY = 50.0))), + TouchEvent("touchmove", touchEventWithChangedTouchesInit(createTouch(0, getCanvas(), clientX = 60.0, clientY = 60.0))) ) awaitIdle() @@ -181,6 +185,87 @@ class GesturesTest : OnCanvasTests { assertEquals(PointerEventType.Move, lastPointerEvent!!.type) } + @Test + fun threeTouchesWithTouchEnd() = runApplicationTest { + val pointerEvents = mutableListOf() + + createComposeWindow { + Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { + awaitPointerEventScope { + while (coroutineContext.isActive) { + pointerEvents.add(awaitPointerEvent()) + } + } + }) + } + + assertTrue(pointerEvents.isEmpty()) + + val touch1 = createTouch(1, getCanvas(), clientX = 10.0, clientY = 10.0) + val touch2 = createTouch(2, getCanvas(), clientX = 20.0, clientY = 20.0) + val touch3 = createTouch(3, getCanvas(), clientX = 30.0, clientY = 30.0) + + dispatchEvents( + // +1 + TouchEvent("touchstart", + touchEventInit( + changedTouches = listOf(touch1), + targetTouches = listOf(touch1), + )), + // +2 + TouchEvent("touchstart", + touchEventInit( + changedTouches = listOf(touch2), + targetTouches = listOf(touch1, touch2), + )), + // +3 + TouchEvent("touchstart", + touchEventInit( + changedTouches = listOf(touch3), + targetTouches = listOf(touch1, touch2, touch3), + )), + // -3 + TouchEvent("touchend", + touchEventInit( + changedTouches = listOf(touch3), + targetTouches = listOf(touch1, touch2), + )), + // -2 + TouchEvent("touchend", + touchEventInit( + changedTouches = listOf(touch2), + targetTouches = listOf(touch1), + )), + // -1 + TouchEvent("touchend", + touchEventInit( + changedTouches = listOf(touch1), + targetTouches = listOf(), + )) + ) + + awaitIdle() + + val expected = """ + + 1 + + 1; + 2 + + 1; + 2; + 3 + + 1; + 2; - 3 + + 1; - 2 + - 1 + """.trimIndent() + + val actual = pointerEvents.joinToString("\n") { event -> + event.changes.sortedBy { it.id.value }.joinToString("; ") { + if (it.pressed) { + "+ ${it.id.value}" + } else { + "- ${it.id.value}" + } + } + } + assertEquals(expected, actual) + } } external interface Touch @@ -206,7 +291,7 @@ private fun createTouch( """ ) -private fun touchEventInit(vararg touches: Touch): TouchEventInit = js( +private fun touchEventWithChangedTouchesInit(vararg touches: Touch): TouchEventInit = js( """ ({ bubbles: true, @@ -232,3 +317,30 @@ private fun touchEventWithTargetTouchesInit(vararg touches: Touch): TouchEventIn """ ) +@OptIn(ExperimentalJsCollectionsApi::class) +private fun touchEventInit( + changedTouchesBeforeIndex: Int, + vararg touches: Touch, + ): TouchEventInit = js( + """ + ({ + bubbles: true, + cancelable: true, + composed: true, + changedTouches: touches.slice(0, changedTouchesBeforeIndex), + targetTouches: touches.slice(changedTouchesBeforeIndex), + touches: [] + }) + """ + ) + +@OptIn(ExperimentalJsCollectionsApi::class, ExperimentalJsExport::class) +private fun touchEventInit( + changedTouches: List, + targetTouches: List, +): TouchEventInit = touchEventInit( + changedTouchesBeforeIndex = changedTouches.size, + *arrayOf(*changedTouches.toTypedArray(), *targetTouches.toTypedArray()) +) + + From 1055f75d0b9d1b1aaf38771ad6b931b8fe0fba32 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 15 Oct 2025 16:24:52 +0200 Subject: [PATCH 2/4] format test --- .../androidx/compose/ui/input/GesturesTest.kt | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) 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 b0e5ae4befb98..1076a1a2878c7 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 @@ -207,41 +207,53 @@ class GesturesTest : OnCanvasTests { dispatchEvents( // +1 - TouchEvent("touchstart", + TouchEvent( + "touchstart", touchEventInit( - changedTouches = listOf(touch1), - targetTouches = listOf(touch1), - )), + changedTouches = listOf(touch1), + targetTouches = listOf(touch1), + ) + ), // +2 - TouchEvent("touchstart", + TouchEvent( + "touchstart", touchEventInit( - changedTouches = listOf(touch2), - targetTouches = listOf(touch1, touch2), - )), + changedTouches = listOf(touch2), + targetTouches = listOf(touch1, touch2), + ) + ), // +3 - TouchEvent("touchstart", + TouchEvent( + "touchstart", touchEventInit( changedTouches = listOf(touch3), targetTouches = listOf(touch1, touch2, touch3), - )), + ) + ), // -3 - TouchEvent("touchend", + TouchEvent( + "touchend", touchEventInit( changedTouches = listOf(touch3), targetTouches = listOf(touch1, touch2), - )), + ) + ), // -2 - TouchEvent("touchend", + TouchEvent( + "touchend", touchEventInit( changedTouches = listOf(touch2), targetTouches = listOf(touch1), - )), + ) + ), // -1 - TouchEvent("touchend", + TouchEvent( + "touchend", touchEventInit( changedTouches = listOf(touch1), targetTouches = listOf(), - )) + ) + ) ) awaitIdle() @@ -298,7 +310,7 @@ private fun touchEventWithChangedTouchesInit(vararg touches: Touch): TouchEventI cancelable: true, composed: true, changedTouches: touches, - targetTouches: [], + targetTouches: touches, touches: [] }) """ @@ -321,8 +333,8 @@ private fun touchEventWithTargetTouchesInit(vararg touches: Touch): TouchEventIn private fun touchEventInit( changedTouchesBeforeIndex: Int, vararg touches: Touch, - ): TouchEventInit = js( - """ +): TouchEventInit = js( + """ ({ bubbles: true, cancelable: true, @@ -332,7 +344,7 @@ private fun touchEventInit( touches: [] }) """ - ) +) @OptIn(ExperimentalJsCollectionsApi::class, ExperimentalJsExport::class) private fun touchEventInit( From 7e440d900060989a0e68bb5c785aac4689c4a539 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 15 Oct 2025 16:25:49 +0200 Subject: [PATCH 3/4] fix incorrect handling of multi-touch input --- .../compose/ui/window/ComposeWindow.w3c.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt index 45c274f1d60ce..da9357058853c 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt @@ -510,22 +510,22 @@ internal class ComposeWindow( * - changedTouches contains only a Touch of a changed pointer, but compose needs all pointers, * therefore we take targetTouches in this case; */ - val touches = if (event.targetTouches.length > event.changedTouches.length) { - event.targetTouches.asList() + val isChangedTouchPressed = + eventType == PointerEventType.Press || eventType == PointerEventType.Move + val touches = if (isChangedTouchPressed) { + event.targetTouches.asList().fastMap { it to true } } else { - event.changedTouches.asList() + event.changedTouches.asList().fastMap { it to false } + event.targetTouches.asList() + .fastMap { it to true } } - val pointers = touches.fastMap { touch -> + val pointers = touches.fastMap { (touch, pressed) -> ComposeScenePointer( id = PointerId(touch.identifier.toLong()), position = Offset( x = touch.clientX - offset.x, y = touch.clientY - offset.y ) * density.density, - pressed = when (eventType) { - PointerEventType.Press, PointerEventType.Move -> true - else -> false - }, + pressed = pressed, type = PointerType.Touch, pressure = touch.unsafeCast().force.toFloat() ) From 4a51406336261593ad202d5d0cfeddab2e0759a0 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 17 Oct 2025 09:29:15 +0200 Subject: [PATCH 4/4] review --- .../compose/ui/window/ComposeWindow.w3c.kt | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt index da9357058853c..412c902a6d49b 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt @@ -102,6 +102,7 @@ import org.w3c.dom.events.Event import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.WheelEvent +import org.w3c.dom.pointerevents.PointerEventInit private val actualDensity get() = window.devicePixelRatio @@ -505,19 +506,15 @@ internal class ComposeWindow( } /** - * We use both targetTouches and changedTouches: - * - targetTouches is empty when a last pointer is released, but changedTouches won't be empty; - * - changedTouches contains only a Touch of a changed pointer, but compose needs all pointers, - * therefore we take targetTouches in this case; + * The set of touches needed for compose are: + * - targetTouches: contains all pressed touches for the current target element + * - changedTouches when the event is 'touchend' or 'touchcancel': contains released touches */ - val isChangedTouchPressed = - eventType == PointerEventType.Press || eventType == PointerEventType.Move - val touches = if (isChangedTouchPressed) { - event.targetTouches.asList().fastMap { it to true } - } else { - event.changedTouches.asList().fastMap { it to false } + event.targetTouches.asList() - .fastMap { it to true } + val touches = event.targetTouches.asList().fastMap { it to true }.toMutableList() + if (eventType == PointerEventType.Release) { + touches.addAll(event.changedTouches.asList().fastMap { it to false } ) } + val pointers = touches.fastMap { (touch, pressed) -> ComposeScenePointer( id = PointerId(touch.identifier.toLong()),