Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion resources/android/GestureAreaRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
Expand Down Expand Up @@ -72,7 +73,10 @@ import kotlin.math.abs
* - `on_swipe` (int) — callback fired on a directional
* swipe, carrying "left" / "right" /
* "up" / "down" as text (rides the
* TEXT_CHANGE format).
* TEXT_CHANGE format). Once it passes
* touch slop the drag is the swipe's,
* so a pressable inside or around the
* area doesn't also fire.
* - `swipe-fingers` (int) — touches required for the swipe
* (default 1; Jump-style is 3).
* - `bounded` (bool) — keep the zoomed content inside the
Expand Down Expand Up @@ -309,6 +313,7 @@ object GestureAreaRenderer {
.pointerInput(onSwipe, swipeFingers) {
if (onSwipe == 0) return@pointerInput
val threshold = 60.dp.toPx()
val slop = viewConfiguration.touchSlop
awaitEachGesture {
// Accumulate the centroid pan while exactly the
// required finger count is down; resolve to a
Expand All @@ -317,6 +322,10 @@ object GestureAreaRenderer {
// swipe from also triggering 1-finger handlers.
var maxPointers = 0
var pan = Offset.Zero
// Whether the pan has passed touch slop yet, and
// whether this swipe then took the drag for itself.
var pastSlop = false
var claimed = false
awaitFirstDown(requireUnconsumed = false)
var pressed = true
while (pressed) {
Expand All @@ -338,6 +347,35 @@ object GestureAreaRenderer {
pan += delta / count.toFloat()
}
pressed = event.changes.any { it.pressed }
// Past touch slop the gesture is a swipe, not a
// tap, as it is on iOS where a tap fails once the
// touch moves. Compose's clickable only cancels
// when the pointer leaves it or a change is
// consumed, so consume the rest of the gesture: a
// pressable inside the area or around it then
// doesn't also fire on release. Leave the drag to
// anything that took it first in the Main pass,
// such as an enclosing scroll container or the
// area's own pan, so a vertical drag through a
// list of swipeable rows still scrolls the list.
if (claimed) {
if (maxPointers == swipeFingers) {
event.changes.forEach { it.consume() }
}
} else if (!pastSlop && count == swipeFingers &&
maxOf(abs(pan.x), abs(pan.y)) >= slop
) {
pastSlop = true
// The Final pass of this same event: every
// handler has had its Main pass by now, and a
// pressable inside checks this pass for a
// consumed change.
val final = awaitPointerEvent(PointerEventPass.Final)
if (final.changes.none { it.isConsumed }) {
claimed = true
final.changes.forEach { it.consume() }
}
}
}
if (maxPointers == swipeFingers) {
val direction = when {
Expand Down
12 changes: 10 additions & 2 deletions tests/android/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Android focused-input regression tests
# Android focused-input and gesture regression tests

This standalone Robolectric/Compose module compiles the production filled and
outlined editors, their shared dispatcher, and the production Button renderer.
outlined editors, their shared dispatcher, the production Button renderer and
the production gesture area renderer.
It records the bridge event boundary; it does not load PHP/JNI, render icons or
menus, or prove device IME behaviour. The Mobile policy and node models are read
from an explicit checkout, not substituted by test implementations.
Expand All @@ -19,3 +20,10 @@ with and without keep-focus-on-submit. They edit while focused, replace the
change callback during composition, then invoke the separate Button's semantic
click action. The latest change must precede press with no duplicate debounce
publication. Native device testing is still needed for tap/IME focus handling.

`GestureAreaSwipeTest` swipes over a pressable inside a gesture area, inside a
pressable around one, and with two fingers, and checks only `@swipe` fires. It
also checks a tap still presses, a vertical drag still scrolls an enclosing
scroll container, and a bound pan still follows the finger. `NodeView` here
stands in for core's: each child is a fixed-size box, clickable like a core
`pressable` when it has a press handler.
4 changes: 2 additions & 2 deletions tests/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ plugins {
}
val stageSources = tasks.register<Sync>("stageSources") {
from(layout.projectDirectory.dir("../../resources/android")) {
include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt")
include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt")
}
from(providers.gradleProperty("mobileRoot").map { "$it/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender" }) {
include("KeyboardFocusPolicy.kt", "NativeUINode.kt")
include("KeyboardFocusPolicy.kt", "NativeUINode.kt", "SharedValueStore.kt")
}
into(layout.buildDirectory.dir("generated/upstream"))
}
Expand Down
1 change: 1 addition & 0 deletions tests/android/src/main/kotlin/Bridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ object NativeUIBridge {
fun sendTextChangeEvent(callbackId: Int, nodeId: Int, text: String) { events += "change:$callbackId:$text" }
fun sendSubmitEvent(callbackId: Int, nodeId: Int, text: String) { events += "submit:$callbackId:$text" }
fun sendPressEvent(callbackId: Int, nodeId: Int) { events += "press:$callbackId" }
fun sendSliderChangeEvent(callbackId: Int, nodeId: Int, value: Float) { events += "slider:$callbackId:$value" }
}
fun argbToComposeColor(argb: Int) = Color(argb)
23 changes: 23 additions & 0 deletions tests/android/src/main/kotlin/NodeView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.nativephp.mobile.ui.nativerender

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp

/**
* Draws a container's children for the gesture area tests: a 300x120 box
* tagged `node-<id>`, made clickable the way core makes a `pressable` when
* the node has a press handler.
*/
@Composable
fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) {
var modifier = Modifier.size(300.dp, 120.dp).testTag("node-${node.id}")
if (node.onPress != 0) {
modifier = modifier.clickable { NativeUIBridge.sendPressEvent(node.onPress, node.id) }
}
Box(modifier)
}
122 changes: 122 additions & 0 deletions tests/android/src/test/kotlin/GestureAreaSwipeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.nativephp.plugins.native_ui.ui

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* A swipe over a pressable fires the gesture area's `@swipe` and not the
* pressable's `@press` as well, as on iOS, without taking a drag away from
* an enclosing scroll container or the area's own pan.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GestureAreaSwipeTest {
@get:Rule val compose = createComposeRule()

@After fun reset() {
NativeUIBridge.events.clear()
}

@Test fun swipeOverAPressableInsideTheAreaOnlySwipes() {
compose.setContent { GestureAreaRenderer.Render(area(pressable()), Modifier) }
compose.onNodeWithTag("node-2").performTouchInput { swipeLeft() }
compose.runOnIdle { assertEquals(listOf("change:5:left"), events()) }
}

@Test fun tapOnAPressableInsideTheAreaStillPresses() {
compose.setContent { GestureAreaRenderer.Render(area(pressable()), Modifier) }
compose.onNodeWithTag("node-2").performTouchInput { click() }
compose.runOnIdle { assertEquals(listOf("press:9"), events()) }
}

@Test fun swipeInsideAPressableAroundTheAreaOnlySwipes() {
compose.setContent {
Box(Modifier.clickable { NativeUIBridge.events += "outer press" }) {
GestureAreaRenderer.Render(area(node(3)), Modifier)
}
}
compose.onNodeWithTag("node-3", useUnmergedTree = true).performTouchInput { swipeRight() }
compose.runOnIdle { assertEquals(listOf("change:5:right"), events()) }
}

@Test fun twoFingerSwipeOverAPressableOnlySwipes() {
compose.setContent { GestureAreaRenderer.Render(area(pressable(), fingers = 2), Modifier) }
compose.onNodeWithTag("node-2").performTouchInput {
down(0, centerRight - Offset(20f, 20f))
down(1, centerRight - Offset(20f, -20f))
repeat(10) {
updatePointerBy(0, Offset(-width / 12f, 0f))
updatePointerBy(1, Offset(-width / 12f, 0f))
move()
}
up(0)
up(1)
}
compose.runOnIdle { assertEquals(listOf("change:5:left"), events()) }
}

@Test fun verticalDragStillScrollsAnEnclosingScrollContainer() {
val scroll = ScrollState(0)
compose.setContent {
Column(Modifier.height(200.dp).verticalScroll(scroll)) {
GestureAreaRenderer.Render(area(pressable()), Modifier)
Spacer(Modifier.height(1000.dp))
}
}
compose.onNodeWithTag("node-2").performTouchInput { swipeUp() }
compose.runOnIdle {
assertTrue("scrolled to ${scroll.value}", scroll.value > 0)
assertTrue(events().toString(), "press:9" !in events())
}
}

@Test fun areaWithAPanStillPansWhenItAlsoSwipes() {
compose.setContent {
GestureAreaRenderer.Render(area(pressable(), props = mapOf("pan-x-id" to 4101)), Modifier)
}
compose.onNodeWithTag("node-2").performTouchInput { swipeLeft() }
compose.runOnIdle {
assertTrue("panned to ${SharedValueStore.valueOf(4101)}", SharedValueStore.valueOf(4101) < -100f)
assertEquals(listOf("change:5:left"), events())
}
}

/**
* This test's swipe and press events. Another test's debounced text
* change can land in the shared bridge log while one of these runs.
*/
private fun events() = NativeUIBridge.events.filter {
it.startsWith("change:5:") || it == "press:9" || it == "outer press"
}

private fun area(child: NativeUINode, fingers: Int = 1, props: Map<String, Any> = emptyMap()) =
node(1, mapOf("on_swipe" to 5, "swipe-fingers" to fingers) + props, children = listOf(child))

private fun pressable() = node(2, onPress = 9)

private fun node(
id: Int,
props: Map<String, Any> = emptyMap(),
onPress: Int = 0,
children: List<NativeUINode> = emptyList(),
) = NativeUINode(id, "", null, null, GenericProps(props), onPress, 0, children)
}
Loading