Migrate pointerInput to Modifier.Node.
Test: Used existing tests.
Change-Id: I47f213927a6fde605c563d47af683694715eeab3
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
index ef7040b..592c0df 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
@@ -25,6 +25,7 @@
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
@@ -48,12 +49,14 @@
@get:Rule
val rule = createComposeRule()
+ private val tag = "pointerInputTag"
+
@Test
fun awaitEachGestureInternalCancellation() {
val inputLatch = CountDownLatch(1)
rule.setContent {
Box(
- Modifier.pointerInput(Unit) {
+ Modifier.testTag(tag).pointerInput(Unit) {
try {
var count = 0
coroutineScope {
@@ -79,6 +82,7 @@
)
}
rule.waitForIdle()
+ rule.onNodeWithTag(tag).performTouchInput { click(Offset.Zero) }
assertThat(inputLatch.await(1, TimeUnit.SECONDS)).isTrue()
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
index 2fcb00d..5660223 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
@@ -20,8 +20,13 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -45,16 +50,20 @@
@get:Rule
val rule = createComposeRule()
+ private val tag = "pointerInputTag"
+
/**
- * Make sure that an empty `forEachGesture` block does not cause a crash.
+ * Make sure that a single `forEachGesture` block does not cause a crash.
+ * Note: Is is no longer possible for an empty gesture since pointerInput() is started lazily.
*/
+ // TODO (jjw): Check with George that this test is needed anymore.
@Test
- fun testEmptyForEachGesture() {
+ fun testSingleTapForEachGesture() {
val latch1 = CountDownLatch(2)
val latch2 = CountDownLatch(1)
rule.setContent {
Box(
- Modifier.pointerInput(Unit) {
+ Modifier.testTag(tag).pointerInput(Unit) {
forEachGesture {
if (latch1.count == 0L) {
// forEachGesture will loop infinitely with nothing in the middle
@@ -65,12 +74,17 @@
}
}.pointerInput(Unit) {
awaitPointerEventScope {
- assertTrue(currentEvent.changes.isEmpty())
+ // there is no awaitPointerEvent() / loop here, so it will only
+ // execute once.
+ assertTrue(currentEvent.changes.size == 1)
latch2.countDown()
}
}.size(10.dp)
)
}
+ rule.waitForIdle()
+ rule.onNodeWithTag(tag).performTouchInput { click(Offset.Zero) }
+
assertTrue(latch1.await(1, TimeUnit.SECONDS))
assertTrue(latch2.await(1, TimeUnit.SECONDS))
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt
new file mode 100644
index 0000000..8c4d47f
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt
@@ -0,0 +1,905 @@
+/*
+ * Copyright 2023 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.foundation.gesture
+
+import androidx.compose.foundation.gestures.GestureCancellationException
+import androidx.compose.foundation.gestures.detectTapAndPress
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(JUnit4::class)
+class TapGestureDetectorTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ private var pressed = false
+ private var released = false
+ private var canceled = false
+ private var tapped = false
+ private var doubleTapped = false
+ private var longPressed = false
+
+ /** The time before a long press gesture attempts to win. */
+ private val LongPressTimeoutMillis: Long = 500L
+
+ /**
+ * The maximum time from the start of the first tap to the start of the second
+ * tap in a double-tap gesture.
+ */
+ // TODO(shepshapard): In Android, this is actually the time from the first's up event
+ // to the second's down event, according to the ViewConfiguration docs.
+ private val DoubleTapTimeoutMillis: Long = 300L
+
+ private val util = layoutWithGestureDetector {
+ detectTapGestures(
+ onPress = {
+ pressed = true
+ if (tryAwaitRelease()) {
+ released = true
+ } else {
+ canceled = true
+ }
+ },
+ onTap = {
+ tapped = true
+ }
+ )
+ }
+
+ private val utilWithShortcut = layoutWithGestureDetector {
+ detectTapAndPress(
+ onPress = {
+ pressed = true
+ if (tryAwaitRelease()) {
+ released = true
+ } else {
+ canceled = true
+ }
+ },
+ onTap = {
+ tapped = true
+ }
+ )
+ }
+
+ private val allGestures = layoutWithGestureDetector {
+ detectTapGestures(
+ onPress = {
+ pressed = true
+ try {
+ awaitRelease()
+ released = true
+ } catch (_: GestureCancellationException) {
+ canceled = true
+ }
+ },
+ onTap = { tapped = true },
+ onLongPress = { longPressed = true },
+ onDoubleTap = { doubleTapped = true }
+ )
+ }
+
+ private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+ private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+ private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+ @Before
+ fun setup() {
+ pressed = false
+ released = false
+ canceled = false
+ tapped = false
+ doubleTapped = false
+ longPressed = false
+ }
+
+ private fun layoutWithGestureDetector(
+ gestureDetector: suspend PointerInputScope.() -> Unit,
+ ): @Composable () -> Unit = {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalViewConfiguration provides TestViewConfiguration(
+ minimumTouchTargetSize = DpSize.Zero
+ )
+ ) {
+ with(LocalDensity.current) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ // Some tests execute a lambda before the initial and final passes
+ // so they are called here, higher up the chain, so that the
+ // calls happen prior to the gestureDetector below. The lambdas
+ // do things like consume events on the initial pass or validate
+ // consumption on the final pass.
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent(PointerEventPass.Initial)
+ event.changes.forEach {
+ initialPass(it)
+ }
+ awaitPointerEvent(PointerEventPass.Final)
+ event.changes.forEach {
+ finalPass(it)
+ }
+ }
+ }
+ }
+ .wrapContentSize(AbsoluteAlignment.TopLeft)
+ .size(10.toDp())
+ .pointerInput(gestureDetector, gestureDetector)
+ .testTag(TargetTag)
+ )
+ }
+ }
+ }
+
+ private fun performTouch(
+ initialPass: PointerInputChange.() -> Unit = nothingHandler,
+ finalPass: PointerInputChange.() -> Unit = nothingHandler,
+ block: TouchInjectionScope.() -> Unit
+ ) {
+ this.initialPass = initialPass
+ this.finalPass = finalPass
+ rule.onNodeWithTag(TargetTag).performTouchInput(block)
+ rule.waitForIdle()
+ this.initialPass = nothingHandler
+ this.finalPass = nothingHandler
+ }
+
+ /**
+ * Clicking in the region should result in the callback being invoked.
+ */
+ @Test
+ fun normalTap() {
+ rule.setContent(util)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertTrue(pressed)
+ assertFalse(tapped)
+ assertFalse(released)
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(tapped)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * Clicking in the region should result in the callback being invoked.
+ */
+ @Test
+ fun normalTap_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertTrue(pressed)
+ assertFalse(tapped)
+ assertFalse(released)
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(tapped)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * Clicking in the region should result in the callback being invoked.
+ */
+ @Test
+ fun normalTapWithAllGestures() {
+ rule.setContent(allGestures)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertTrue(pressed)
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(released)
+
+ // we have to wait for the double-tap timeout before we receive an event
+
+ assertFalse(tapped)
+ assertFalse(doubleTapped)
+
+ rule.mainClock.advanceTimeBy(DoubleTapTimeoutMillis + 10)
+
+ assertTrue(tapped)
+ assertFalse(doubleTapped)
+ }
+
+ /**
+ * Clicking in the region should result in the callback being invoked.
+ */
+ @Test
+ fun normalDoubleTap() {
+ rule.setContent(allGestures)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(tapped)
+ assertFalse(doubleTapped)
+
+ pressed = false
+ released = false
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(doubleTapped)
+ assertTrue(pressed)
+ assertTrue(released)
+ }
+
+ /**
+ * Long press in the region should result in the callback being invoked.
+ */
+ @Test
+ fun normalLongPress() {
+ rule.setContent(allGestures)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertTrue(pressed)
+
+ rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+ assertTrue(longPressed)
+
+ rule.mainClock.advanceTimeBy(500)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(doubleTapped)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * Pressing in the region, sliding out and then lifting should result in
+ * the callback not being invoked
+ */
+ @Test
+ fun tapMiss() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ moveTo(0, Offset(15f, 15f))
+ }
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(canceled)
+ assertFalse(released)
+ assertFalse(tapped)
+ }
+
+ /**
+ * Pressing in the region, sliding out and then lifting should result in
+ * the callback not being invoked
+ */
+ @Test
+ fun tapMiss_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ moveTo(0, Offset(15f, 15f))
+ }
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(canceled)
+ assertFalse(released)
+ assertFalse(tapped)
+ }
+
+ /**
+ * Pressing in the region, sliding out and then lifting should result in
+ * the callback not being invoked
+ */
+ @Test
+ fun longPressMiss() {
+ rule.setContent(allGestures)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ moveTo(0, Offset(15f, 15f))
+ }
+
+ rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ assertFalse(tapped)
+ assertFalse(longPressed)
+ assertFalse(doubleTapped)
+ }
+
+ /**
+ * Pressing in the region, sliding out and then lifting should result in
+ * the callback not being invoked for double-tap
+ */
+ @Test
+ fun doubleTapMiss() {
+ rule.setContent(allGestures)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+
+ pressed = false
+ released = false
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch {
+ down(1, Offset(5f, 5f))
+ moveTo(1, Offset(15f, 15f))
+ }
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(1)
+ }
+
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ assertTrue(tapped)
+ assertFalse(longPressed)
+ assertFalse(doubleTapped)
+ }
+
+ /**
+ * Pressing in the region, sliding out, then back in, then lifting
+ * should result the gesture being canceled.
+ */
+ @Test
+ fun tapOutAndIn() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ moveTo(0, Offset(15f, 15f))
+ moveTo(0, Offset(6f, 6f))
+ }
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Pressing in the region, sliding out, then back in, then lifting
+ * should result the gesture being canceled.
+ */
+ @Test
+ fun tapOutAndIn_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ moveTo(0, Offset(15f, 15f))
+ moveTo(0, Offset(6f, 6f))
+ }
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * After a first tap, a second tap should also be detected.
+ */
+ @Test
+ fun secondTap() {
+ rule.setContent(util)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+
+ tapped = false
+ pressed = false
+ released = false
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(1, Offset(4f, 4f))
+ up(1)
+ }
+
+ assertTrue(tapped)
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * After a first tap, a second tap should also be detected.
+ */
+ @Test
+ fun secondTap_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+
+ tapped = false
+ pressed = false
+ released = false
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ up(0)
+ }
+
+ assertTrue(tapped)
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * Clicking in the region with the up already consumed should result in the callback not
+ * being invoked.
+ */
+ @Test
+ fun consumedUpTap() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+
+ performTouch(initialPass = { if (pressed != previousPressed) consume() }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Clicking in the region with the up already consumed should result in the callback not
+ * being invoked.
+ */
+ @Test
+ fun consumedUpTap_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+
+ performTouch(initialPass = { if (pressed != previousPressed) consume() }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Clicking in the region with the motion consumed should result in the callback not
+ * being invoked.
+ */
+ @Test
+ fun consumedMotionTap() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveTo(0, Offset(6f, 2f))
+ }
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Clicking in the region with the motion consumed should result in the callback not
+ * being invoked.
+ */
+ @Test
+ fun consumedMotionTap_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveTo(0, Offset(6f, 2f))
+ }
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ @Test
+ fun consumedChange_MotionTap() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveTo(0, Offset(6f, 2f))
+ }
+
+ rule.mainClock.advanceTimeBy(50)
+
+ performTouch {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Clicking in the region with the up already consumed should result in the callback not
+ * being invoked.
+ */
+ @Test
+ fun consumedChange_upTap() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertFalse(tapped)
+ assertTrue(pressed)
+
+ performTouch(initialPass = { consume() }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+ assertTrue(canceled)
+ }
+
+ /**
+ * Ensure that two-finger taps work.
+ */
+ @Test
+ fun twoFingerTap() {
+ rule.setContent(util)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(1f, 1f))
+ }
+
+ assertTrue(pressed)
+ pressed = false
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ down(1, Offset(9f, 5f))
+ }
+
+ assertFalse(pressed)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(1)
+ }
+
+ assertTrue(tapped)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * Ensure that two-finger taps work.
+ */
+ @Test
+ fun twoFingerTap_withShortcut() {
+ rule.setContent(utilWithShortcut)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ down(0, Offset(1f, 1f))
+ }
+
+ assertTrue(pressed)
+ pressed = false
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ down(1, Offset(9f, 5f))
+ }
+
+ assertFalse(pressed)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ up(1)
+ }
+
+ assertTrue(tapped)
+ assertTrue(released)
+ assertFalse(canceled)
+ }
+
+ /**
+ * A position change consumption on any finger should cause tap to cancel.
+ */
+ @Test
+ fun twoFingerTapCancel() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(1f, 1f))
+ }
+ assertTrue(pressed)
+
+ performTouch {
+ down(1, Offset(9f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveTo(0, Offset(5f, 5f))
+ }
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(canceled)
+
+ rule.mainClock.advanceTimeBy(50)
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(1)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+ }
+
+ /**
+ * A position change consumption on any finger should cause tap to cancel.
+ */
+ @Test
+ fun twoFingerTapCancel_withShortcut() {
+ rule.setContent(utilWithShortcut)
+ performTouch {
+ down(0, Offset(1f, 1f))
+ }
+
+ assertTrue(pressed)
+
+ performTouch {
+ down(1, Offset(9f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveTo(0, Offset(5f, 5f))
+ }
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(tapped)
+ assertTrue(canceled)
+
+ rule.mainClock.advanceTimeBy(50)
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(1)
+ }
+
+ assertFalse(tapped)
+ assertFalse(released)
+ }
+
+ /**
+ * Detect the second tap as long press.
+ */
+ @Test
+ fun secondTapLongPress() {
+ rule.setContent(allGestures)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ up(0)
+ }
+
+ assertTrue(pressed)
+ assertTrue(released)
+ assertFalse(canceled)
+ assertFalse(tapped)
+ assertFalse(doubleTapped)
+ assertFalse(longPressed)
+
+ pressed = false
+ released = false
+
+ rule.mainClock.advanceTimeBy(50)
+ performTouch {
+ down(1, Offset(5f, 5f))
+ }
+
+ assertTrue(pressed)
+
+ rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+ assertTrue(tapped)
+ assertTrue(longPressed)
+ assertFalse(released)
+ assertFalse(canceled)
+
+ rule.mainClock.advanceTimeBy(500)
+ performTouch {
+ up(1)
+ }
+ assertTrue(released)
+ }
+}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt
new file mode 100644
index 0000000..44a51df
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt
@@ -0,0 +1,606 @@
+/*
+ * Copyright 2023 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.foundation.gesture
+
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(Parameterized::class)
+class TransformGestureDetectorTest(val panZoomLock: Boolean) {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters
+ fun parameters() = arrayOf(false, true)
+ }
+
+ private var centroid = Offset.Zero
+ private var panned = false
+ private var panAmount = Offset.Zero
+ private var rotated = false
+ private var rotateAmount = 0f
+ private var zoomed = false
+ private var zoomAmount = 1f
+
+ private val util = layoutWithGestureDetector {
+ detectTransformGestures(
+ panZoomLock = panZoomLock
+ ) { c, pan, gestureZoom, gestureAngle ->
+ centroid = c
+ if (gestureAngle != 0f) {
+ rotated = true
+ rotateAmount += gestureAngle
+ }
+ if (gestureZoom != 1f) {
+ zoomed = true
+ zoomAmount *= gestureZoom
+ }
+ if (pan != Offset.Zero) {
+ panned = true
+ panAmount += pan
+ }
+ }
+ }
+
+ private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+ private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+ private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+ @Before
+ fun setup() {
+ panned = false
+ panAmount = Offset.Zero
+ rotated = false
+ rotateAmount = 0f
+ zoomed = false
+ zoomAmount = 1f
+ }
+
+ private fun layoutWithGestureDetector(
+ gestureDetector: suspend PointerInputScope.() -> Unit,
+ ): @Composable () -> Unit = {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalViewConfiguration provides TestViewConfiguration(
+ minimumTouchTargetSize = DpSize.Zero
+ )
+ ) {
+ with(LocalDensity.current) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ // Some tests execute a lambda before the initial and final passes
+ // so they are called here, higher up the chain, so that the
+ // calls happen prior to the gestureDetector below. The lambdas
+ // do things like consume events on the initial pass or validate
+ // consumption on the final pass.
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent(PointerEventPass.Initial)
+ event.changes.forEach {
+ initialPass(it)
+ }
+ awaitPointerEvent(PointerEventPass.Final)
+ event.changes.forEach {
+ finalPass(it)
+ }
+ }
+ }
+ }
+ .wrapContentSize(AbsoluteAlignment.TopLeft)
+ .size(1600.toDp())
+ .pointerInput(gestureDetector, gestureDetector)
+ .testTag(TargetTag)
+ )
+ }
+ }
+ }
+
+ private fun performTouch(
+ initialPass: PointerInputChange.() -> Unit = nothingHandler,
+ finalPass: PointerInputChange.() -> Unit = nothingHandler,
+ block: TouchInjectionScope.() -> Unit
+ ) {
+ this.initialPass = initialPass
+ this.finalPass = finalPass
+ rule.onNodeWithTag(TargetTag).performTouchInput(block)
+ rule.waitForIdle()
+ this.initialPass = nothingHandler
+ this.finalPass = nothingHandler
+ }
+
+ /**
+ * Single finger pan.
+ */
+ @Test
+ fun singleFingerPan() {
+ rule.setContent(util)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ down(0, Offset(5f, 5f))
+ }
+
+ assertFalse(panned)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ moveBy(0, Offset(12.7f, 12.7f))
+ }
+
+ assertFalse(panned)
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ moveBy(0, Offset(0.1f, 0.1f))
+ }
+
+ assertEquals(17.7f, centroid.x, 0.1f)
+ assertEquals(17.7f, centroid.y, 0.1f)
+ assertTrue(panned)
+ assertFalse(zoomed)
+ assertFalse(rotated)
+
+ assertTrue(panAmount.getDistance() < 1f)
+
+ panAmount = Offset.Zero
+
+ performTouch(finalPass = { assertTrue(isConsumed) }) {
+ moveBy(0, Offset(1f, 0f))
+ }
+
+ assertEquals(Offset(1f, 0f), panAmount)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ up(0)
+ }
+
+ assertFalse(rotated)
+ assertFalse(zoomed)
+ }
+
+ /**
+ * Multi-finger pan
+ */
+ @Test
+ fun multiFingerPanZoom() {
+ rule.setContent(util)
+
+ // [PointerId] needed later to assert whether or not a particular pointer id was consumed.
+ var pointerId0: PointerId? = null
+ var pointerId1: PointerId? = null
+
+ performTouch(
+ finalPass = {
+ pointerId0 = id
+ assertFalse(isConsumed)
+ }
+ ) {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(
+ finalPass = {
+ if (id != pointerId0) {
+ pointerId1 = id
+ }
+ assertFalse(isConsumed)
+ }
+ ) {
+ down(1, Offset(25f, 25f))
+ }
+
+ assertFalse(panned)
+
+ performTouch(finalPass = { assertFalse(isConsumed) }) {
+ moveBy(0, Offset(13f, 13f))
+ }
+
+ // With the move below, we've now averaged enough movement (touchSlop is around 18.0)
+ performTouch(
+ finalPass = {
+ if (id == pointerId1) {
+ assertTrue(isConsumed)
+ }
+ }
+ ) {
+ moveBy(1, Offset(13f, 13f))
+ }
+
+ assertEquals((5f + 25f + 13f) / 2f, centroid.x, 0.1f)
+ assertEquals((5f + 25f + 13f) / 2f, centroid.y, 0.1f)
+ assertTrue(panned)
+ assertTrue(zoomed)
+ assertFalse(rotated)
+
+ assertEquals(6.4f, panAmount.x, 0.1f)
+ assertEquals(6.4f, panAmount.y, 0.1f)
+
+ performTouch {
+ up(0)
+ up(1)
+ }
+ }
+
+ /**
+ * 2-pointer zoom
+ */
+ @Test
+ fun zoom2Pointer() {
+ rule.setContent(util)
+
+ // [PointerId] needed later to assert whether or not a particular pointer id was consumed.
+ var pointerId0: PointerId? = null
+ var pointerId1: PointerId? = null
+
+ performTouch(
+ finalPass = {
+ pointerId0 = id
+ assertFalse(isConsumed)
+ }
+ ) {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(
+ finalPass = {
+ if (id != pointerId0) {
+ pointerId1 = id
+ assertFalse(isConsumed)
+ }
+ }
+ ) {
+ down(1, Offset(25f, 5f))
+ }
+
+ performTouch(
+ finalPass = {
+ if (id == pointerId1) {
+ assertFalse(isConsumed)
+ }
+ }
+ ) {
+ moveBy(1, Offset(35.95f, 0f))
+ }
+
+ performTouch(
+ finalPass = {
+ if (id == pointerId1) {
+ assertTrue(isConsumed)
+ }
+ }
+ ) {
+ moveBy(1, Offset(0.1f, 0f))
+ }
+
+ assertTrue(panned)
+ assertTrue(zoomed)
+ assertFalse(rotated)
+
+ // both should be small movements
+ assertTrue(panAmount.getDistance() < 1f)
+ assertTrue(zoomAmount in 1f..1.1f)
+
+ zoomAmount = 1f
+ panAmount = Offset.Zero
+
+ performTouch(
+ finalPass = {
+ if (id == pointerId0) {
+ assertTrue(isConsumed)
+ }
+ }
+ ) {
+ moveBy(0, Offset(-1f, 0f))
+ }
+
+ performTouch(
+ finalPass = {
+ if (id == pointerId1) {
+ assertTrue(isConsumed)
+ }
+ }
+ ) {
+ moveBy(1, Offset(1f, 0f))
+ }
+
+ assertEquals(0f, panAmount.x, 0.01f)
+ assertEquals(0f, panAmount.y, 0.01f)
+
+ assertEquals(48f / 46f, zoomAmount, 0.01f)
+
+ performTouch {
+ up(0)
+ up(1)
+ }
+ }
+
+ /**
+ * 4-pointer zoom
+ */
+ @Test
+ fun zoom4Pointer() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(0f, 50f))
+ }
+
+ // just get past the touch slop
+ performTouch {
+ moveBy(0, Offset(-1000f, 0f))
+ moveBy(0, Offset(1000f, 0f))
+ }
+
+ panned = false
+ panAmount = Offset.Zero
+
+ performTouch {
+ down(1, Offset(100f, 50f))
+ down(2, Offset(50f, 0f))
+ down(3, Offset(50f, 100f))
+ }
+
+ performTouch {
+ moveBy(0, Offset(-50f, 0f))
+ moveBy(1, Offset(50f, 0f))
+ }
+
+ assertTrue(zoomed)
+ assertTrue(panned)
+
+ assertEquals(0f, panAmount.x, 0.1f)
+ assertEquals(0f, panAmount.y, 0.1f)
+ assertEquals(1.5f, zoomAmount, 0.1f)
+
+ performTouch {
+ moveBy(2, Offset(0f, -50f))
+ moveBy(3, Offset(0f, 50f))
+ }
+
+ assertEquals(0f, panAmount.x, 0.1f)
+ assertEquals(0f, panAmount.y, 0.1f)
+ assertEquals(2f, zoomAmount, 0.1f)
+
+ performTouch {
+ up(0)
+ up(1)
+ up(2)
+ up(3)
+ }
+ }
+
+ /**
+ * 2 pointer rotation.
+ */
+ @Test
+ fun rotation2Pointer() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(0f, 50f))
+ down(1, Offset(100f, 50f))
+
+ // Move
+ moveBy(0, Offset(50f, -50f))
+ moveBy(1, Offset(-50f, 50f))
+ }
+
+ // assume some of the above was touch slop
+ assertTrue(rotated)
+ rotateAmount = 0f
+ rotated = false
+ zoomAmount = 1f
+ panAmount = Offset.Zero
+
+ // now do the real rotation:
+ performTouch {
+ moveBy(0, Offset(-50f, 50f))
+ moveBy(1, Offset(50f, -50f))
+ }
+
+ performTouch {
+ up(0)
+ up(1)
+ }
+
+ assertTrue(rotated)
+ assertEquals(-90f, rotateAmount, 0.01f)
+ assertEquals(0f, panAmount.x, 0.1f)
+ assertEquals(0f, panAmount.y, 0.1f)
+ assertEquals(1f, zoomAmount, 0.1f)
+ }
+
+ /**
+ * 2 pointer rotation, with early panning.
+ */
+ @Test
+ fun rotation2PointerLock() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(0f, 50f))
+ }
+
+ // just get past the touch slop with panning
+ performTouch {
+ moveBy(0, Offset(-1000f, 0f))
+ moveBy(0, Offset(1000f, 0f))
+ }
+
+ performTouch {
+ down(1, Offset(100f, 50f))
+ }
+
+ // now do the rotation:
+ performTouch {
+ moveBy(0, Offset(50f, -50f))
+ moveBy(1, Offset(-50f, 50f))
+ }
+
+ performTouch {
+ up(0)
+ up(1)
+ }
+
+ if (panZoomLock) {
+ assertFalse(rotated)
+ } else {
+ assertTrue(rotated)
+ assertEquals(90f, rotateAmount, 0.01f)
+ }
+ assertEquals(0f, panAmount.x, 0.1f)
+ assertEquals(0f, panAmount.y, 0.1f)
+ assertEquals(1f, zoomAmount, 0.1f)
+ }
+
+ /**
+ * Adding or removing a pointer won't change the current values
+ */
+ @Test
+ fun noChangeOnPointerDownUp() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(0f, 50f))
+ down(1, Offset(100f, 50f))
+
+ moveBy(0, Offset(50f, -50f))
+ moveBy(1, Offset(-50f, 50f))
+ }
+
+ // now we've gotten past the touch slop
+ rotated = false
+ panned = false
+ zoomed = false
+
+ performTouch {
+ down(2, Offset(0f, 50f))
+ }
+
+ assertFalse(rotated)
+ assertFalse(panned)
+ assertFalse(zoomed)
+
+ performTouch {
+ down(3, Offset(100f, 50f))
+ }
+
+ assertFalse(rotated)
+ assertFalse(panned)
+ assertFalse(zoomed)
+
+ performTouch {
+ up(0)
+ up(1)
+ up(2)
+ up(3)
+ }
+
+ assertFalse(rotated)
+ assertFalse(panned)
+ assertFalse(zoomed)
+ }
+
+ /**
+ * Consuming position during touch slop will cancel the current gesture.
+ */
+ @Test
+ fun touchSlopCancel() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveBy(0, Offset(50f, 0f))
+ }
+
+ performTouch {
+ up(0)
+ }
+
+ assertFalse(panned)
+ assertFalse(zoomed)
+ assertFalse(rotated)
+ }
+
+ /**
+ * Consuming position after touch slop will cancel the current gesture.
+ */
+ @Test
+ fun afterTouchSlopCancel() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ }
+
+ performTouch {
+ moveBy(0, Offset(50f, 0f))
+ }
+
+ performTouch(initialPass = { consume() }) {
+ moveBy(0, Offset(50f, 0f))
+ }
+
+ performTouch {
+ up(0)
+ }
+
+ assertTrue(panned)
+ assertFalse(zoomed)
+ assertFalse(rotated)
+ assertEquals(50f, panAmount.x, 0.1f)
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
new file mode 100644
index 0000000..a7b9fc5
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2023 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.foundation.text
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import com.google.common.truth.Correspondence
+import com.google.common.truth.IterableSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(JUnit4::class)
+class PointerMoveDetectorTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ private val actualMoves = mutableListOf()
+
+ private val util = layoutWithGestureDetector {
+ detectMoves { actualMoves.add(it) }
+ }
+
+ private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+ private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+ private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+ @Before
+ fun setup() {
+ actualMoves.clear()
+ }
+
+ private fun layoutWithGestureDetector(
+ gestureDetector: suspend PointerInputScope.() -> Unit,
+ ): @Composable () -> Unit = {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalViewConfiguration provides TestViewConfiguration(
+ minimumTouchTargetSize = DpSize.Zero
+ )
+ ) {
+ with(LocalDensity.current) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ // Some tests execute a lambda before the initial and final passes
+ // so they are called here, higher up the chain, so that the
+ // calls happen prior to the gestureDetector below. The lambdas
+ // do things like consume events on the initial pass or validate
+ // consumption on the final pass.
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent(PointerEventPass.Initial)
+ event.changes.forEach {
+ initialPass(it)
+ }
+ awaitPointerEvent(PointerEventPass.Final)
+ event.changes.forEach {
+ finalPass(it)
+ }
+ }
+ }
+ }
+ .wrapContentSize(AbsoluteAlignment.TopLeft)
+ .size(100.toDp())
+ .pointerInput(gestureDetector, gestureDetector)
+ .testTag(TargetTag)
+ )
+ }
+ }
+ }
+
+ private fun performTouch(
+ initialPass: PointerInputChange.() -> Unit = nothingHandler,
+ finalPass: PointerInputChange.() -> Unit = nothingHandler,
+ block: TouchInjectionScope.() -> Unit
+ ) {
+ this.initialPass = initialPass
+ this.finalPass = finalPass
+ rule.onNodeWithTag(TargetTag).performTouchInput(block)
+ rule.waitForIdle()
+ this.initialPass = nothingHandler
+ this.finalPass = nothingHandler
+ }
+
+ @Test
+ fun whenSimpleMovement_allMovesAreReported() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+
+ moveTo(0, Offset(4f, 4f))
+ moveTo(0, Offset(3f, 3f))
+ moveTo(0, Offset(2f, 2f))
+ moveTo(0, Offset(1f, 1f))
+
+ up(0)
+ }
+
+ assertThat(actualMoves).hasEqualOffsets(
+ listOf(
+ Offset(4f, 4f),
+ Offset(3f, 3f),
+ Offset(2f, 2f),
+ Offset(1f, 1f),
+ )
+ )
+ }
+
+ @Test
+ fun whenMultiplePointers_onlyUseFirst() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f))
+ down(1, Offset(6f, 6f))
+
+ moveTo(0, Offset(4f, 4f))
+ moveTo(1, Offset(7f, 7f))
+
+ moveTo(0, Offset(3f, 3f))
+ moveTo(1, Offset(8f, 8f))
+
+ moveTo(0, Offset(2f, 2f))
+ moveTo(1, Offset(9f, 9f))
+
+ moveTo(0, Offset(1f, 1f))
+ moveTo(1, Offset(10f, 10f))
+
+ up(0)
+ up(1)
+ }
+
+ assertThat(actualMoves).hasEqualOffsets(
+ listOf(
+ Offset(4f, 4f),
+ Offset(3f, 3f),
+ Offset(2f, 2f),
+ Offset(1f, 1f),
+ )
+ )
+ }
+
+ @Test
+ fun whenMultiplePointers_thenFirstReleases_handOffToNextPointer() {
+ rule.setContent(util)
+
+ performTouch {
+ down(0, Offset(5f, 5f)) // ignored because not a move
+
+ moveTo(0, Offset(4f, 4f)) // used
+ moveTo(0, Offset(3f, 3f)) // used
+
+ down(1, Offset(4f, 4f)) // ignored because still tracking pointer id 0
+
+ moveTo(0, Offset(2f, 2f)) // used
+ moveTo(1, Offset(3f, 3f)) // ignored because still tracking pointer id 0
+
+ up(0) // ignored because not a move
+
+ moveTo(1, Offset(2f, 2f)) // ignored b/c equal to the previous used move
+ moveTo(1, Offset(1f, 1f)) // used
+
+ up(1) // ignored because not a move
+ }
+
+ assertThat(actualMoves).hasEqualOffsets(
+ listOf(
+ Offset(4f, 4f),
+ Offset(3f, 3f),
+ Offset(2f, 2f),
+ Offset(1f, 1f),
+ )
+ )
+ }
+
+ private fun IterableSubject.hasEqualOffsets(expectedMoves: List) {
+ comparingElementsUsing(offsetCorrespondence)
+ .containsExactly(*expectedMoves.toTypedArray())
+ .inOrder()
+ }
+
+ private val offsetCorrespondence: Correspondence = Correspondence.from(
+ { o1, o2 -> o1!!.x == o2!!.x && o1.y == o2.y },
+ "has the offset of",
+ )
+}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
deleted file mode 100644
index 4d1347a..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
+++ /dev/null
@@ -1,359 +0,0 @@
-/*
- * Copyright 2020 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.foundation.gestures
-
-import androidx.compose.runtime.Applier
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Composer
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.ControlledComposition
-import androidx.compose.runtime.InternalComposeApi
-import androidx.compose.runtime.MonotonicFrameClock
-import androidx.compose.runtime.Recomposer
-import androidx.compose.runtime.currentComposer
-import androidx.compose.runtime.withRunningRecomposer
-import androidx.compose.testutils.TestViewConfiguration
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.pointer.PointerEvent
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.PointerId
-import androidx.compose.ui.input.pointer.PointerInputChange
-import androidx.compose.ui.input.pointer.PointerInputFilter
-import androidx.compose.ui.input.pointer.PointerInputScope
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.materialize
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.IntSize
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.yield
-
-/**
- * Manages suspending pointer input for a single gesture detector, passed in
- * [gestureDetector]. The [width] and [height] of the LayoutNode may
- * be provided.
- */
-internal class SuspendingGestureTestUtil(
- val width: Int = 10,
- val height: Int = 10,
- private val gestureDetector: suspend PointerInputScope.() -> Unit,
-) {
- private var nextPointerId = 0L
- private val activePointers = mutableMapOf()
- private var pointerInputFilter: PointerInputFilter? = null
- private var lastTime = 0L
- private var isExecuting = false
-
- /**
- * Executes the block in composition, creating a gesture detector from
- * [gestureDetector]. The [down], [moveTo], and [up] can then be
- * called within [block].
- *
- * This is not reentrant.
- */
- @OptIn(ExperimentalCoroutinesApi::class)
- fun executeInComposition(block: suspend SuspendingGestureTestUtil.() -> Unit) {
- check(!isExecuting) { "executeInComposition is not reentrant" }
- try {
- isExecuting = true
- runTest {
- val frameClock = TestFrameClock()
-
- withContext(frameClock) {
- composeGesture(block)
- }
- }
- } finally {
- isExecuting = false
- pointerInputFilter = null
- lastTime = 0
- activePointers.clear()
- }
- }
-
- private suspend fun composeGesture(block: suspend SuspendingGestureTestUtil.() -> Unit) {
- withRunningRecomposer { recomposer ->
- compose(recomposer) {
- CompositionLocalProvider(
- LocalDensity provides Density(1f),
- LocalViewConfiguration provides TestViewConfiguration(
- minimumTouchTargetSize = DpSize.Zero
- )
- ) {
- pointerInputFilter = currentComposer
- .materialize(Modifier.pointerInput(Unit, gestureDetector)) as
- PointerInputFilter
- }
- }
- yield()
- block()
- // Pointer input effects will loop indefinitely; fully cancel them.
- recomposer.cancel()
- }
- }
-
- /**
- * Creates a new pointer being down at [timeDiffMillis] from the previous event. The position
- * [x], [y] is used for the touch point. The [PointerInputChange] may be mutated
- * prior to invoking the change on all passes in [initial], if provided. All other "down"
- * pointers will also be included in the change event.
- */
- suspend fun down(
- x: Float,
- y: Float,
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange {
- lastTime += timeDiffMillis
- val change = PointerInputChange(
- id = PointerId(nextPointerId++),
- uptimeMillis = lastTime,
- position = Offset(x, y),
- pressed = true,
- previousUptimeMillis = lastTime,
- previousPosition = Offset(x, y),
- previousPressed = false,
- isInitiallyConsumed = false
- )
- activePointers[change.id] = change
- invokeOverAllPasses(change, initial, main, final)
- return change
- }
-
- /**
- * Creates a new pointer being down at [timeDiffMillis] from the previous event. The position
- * [offset] is used for the touch point. The [PointerInputChange] may be mutated
- * prior to invoking the change on all passes in [initial], if provided. All other "down"
- * pointers will also be included in the change event.
- */
- suspend fun down(
- offset: Offset = Offset.Zero,
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange {
- return down(offset.x, offset.y, timeDiffMillis, main, final, initial)
- }
-
- /**
- * Raises the pointer. [initial] will be called on the [PointerInputChange] prior to the
- * event being invoked on all passes. After [up], the event will no longer participate
- * in other events. [timeDiffMillis] indicates the time from the previous event that
- * the [up] takes place.
- */
- suspend fun PointerInputChange.up(
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange {
- lastTime += timeDiffMillis
- val change = PointerInputChange(
- id = id,
- previousUptimeMillis = uptimeMillis,
- previousPressed = pressed,
- previousPosition = position,
- uptimeMillis = lastTime,
- pressed = false,
- position = position,
- isInitiallyConsumed = false
- )
- activePointers[change.id] = change
- invokeOverAllPasses(change, initial, main, final)
- activePointers.remove(change.id)
- return change
- }
-
- /**
- * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
- * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
- * on all passes.
- */
- suspend fun PointerInputChange.moveTo(
- x: Float,
- y: Float,
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange {
- lastTime += timeDiffMillis
- val change = PointerInputChange(
- id = id,
- previousUptimeMillis = uptimeMillis,
- previousPosition = position,
- previousPressed = pressed,
- uptimeMillis = lastTime,
- position = Offset(x, y),
- pressed = true,
- isInitiallyConsumed = false
- )
- initial(change)
- activePointers[change.id] = change
- invokeOverAllPasses(change, initial, main, final)
- return change
- }
-
- /**
- * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
- * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
- * on all passes.
- */
- suspend fun PointerInputChange.moveTo(
- offset: Offset,
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange = moveTo(offset.x, offset.y, timeDiffMillis, main, final, initial)
-
- /**
- * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
- * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
- * on all passes.
- */
- suspend fun PointerInputChange.moveBy(
- offset: Offset,
- timeDiffMillis: Long = 10,
- main: PointerInputChange.() -> Unit = {},
- final: PointerInputChange.() -> Unit = {},
- initial: PointerInputChange.() -> Unit = {}
- ): PointerInputChange = moveTo(
- position.x + offset.x,
- position.y + offset.y,
- timeDiffMillis,
- main,
- final,
- initial
- )
-
- /**
- * Removes all pointers from the active pointers. This can simulate a faulty pointer stream
- * for robustness testing.
- */
- fun clearPointerStream() {
- activePointers.clear()
- }
-
- /**
- * Updates all changes so that all events are at the current time.
- */
- private fun updateCurrentTime() {
- val currentTime = lastTime
- activePointers.entries.forEach { entry ->
- val change = entry.value
- if (change.uptimeMillis != currentTime) {
- entry.setValue(
- PointerInputChange(
- id = change.id,
- previousUptimeMillis = change.uptimeMillis,
- previousPressed = change.pressed,
- previousPosition = change.position,
- uptimeMillis = currentTime,
- pressed = change.pressed,
- position = change.position,
- isInitiallyConsumed = false
- )
- )
- }
- }
- }
-
- /**
- * Invokes events for all passes.
- */
- private suspend fun invokeOverAllPasses(
- change: PointerInputChange,
- initial: PointerInputChange.() -> Unit,
- main: PointerInputChange.() -> Unit,
- final: PointerInputChange.() -> Unit
- ) {
- updateCurrentTime()
- val event = PointerEvent(activePointers.values.toList())
- val size = IntSize(width, height)
-
- change.initial()
- pointerInputFilter?.onPointerEvent(event, PointerEventPass.Initial, size)
- yield()
- change.main()
- pointerInputFilter?.onPointerEvent(event, PointerEventPass.Main, size)
- yield()
- change.final()
- pointerInputFilter?.onPointerEvent(event, PointerEventPass.Final, size)
- yield()
- }
-
- @OptIn(InternalComposeApi::class)
- private fun compose(
- recomposer: Recomposer,
- block: @Composable () -> Unit
- ) {
- ControlledComposition(
- EmptyApplier(),
- recomposer
- ).apply {
- composeContent {
- @Suppress("UNCHECKED_CAST")
- val fn = block as (Composer, Int) -> Unit
- fn(currentComposer, 0)
- }
- applyChanges()
- verifyConsistent()
- }
- }
-
- internal class TestFrameClock : MonotonicFrameClock {
-
- private val frameCh = Channel()
-
- @Suppress("unused")
- suspend fun frame(frameTimeNanos: Long) {
- frameCh.send(frameTimeNanos)
- }
-
- override suspend fun withFrameNanos(onFrame: (Long) -> R): R =
- onFrame(frameCh.receive())
- }
-
- class EmptyApplier : Applier {
- override val current: Unit = Unit
- override fun down(node: Unit) {}
- override fun up() {}
- override fun insertTopDown(index: Int, instance: Unit) {
- error("Unexpected")
- }
- override fun insertBottomUp(index: Int, instance: Unit) {
- error("Unexpected")
- }
- override fun remove(index: Int, count: Int) {
- error("Unexpected")
- }
- override fun move(from: Int, to: Int, count: Int) {
- error("Unexpected")
- }
- override fun clear() {}
- }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt
deleted file mode 100644
index 6d1613b..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt
+++ /dev/null
@@ -1,649 +0,0 @@
-/*
- * Copyright 2019 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.foundation.gestures
-
-import kotlinx.coroutines.delay
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4::class)
-class TapGestureDetectorTest {
- private var pressed = false
- private var released = false
- private var canceled = false
- private var tapped = false
- private var doubleTapped = false
- private var longPressed = false
-
- /** The time before a long press gesture attempts to win. */
- private val LongPressTimeoutMillis: Long = 500L
-
- /**
- * The maximum time from the start of the first tap to the start of the second
- * tap in a double-tap gesture.
- */
-// TODO(shepshapard): In Android, this is actually the time from the first's up event
-// to the second's down event, according to the ViewConfiguration docs.
- private val DoubleTapTimeoutMillis: Long = 300L
-
- private val util = SuspendingGestureTestUtil {
- detectTapGestures(
- onPress = {
- pressed = true
- if (tryAwaitRelease()) {
- released = true
- } else {
- canceled = true
- }
- },
- onTap = {
- tapped = true
- }
- )
- }
-
- private val utilWithShortcut = SuspendingGestureTestUtil {
- detectTapAndPress(
- onPress = {
- pressed = true
- if (tryAwaitRelease()) {
- released = true
- } else {
- canceled = true
- }
- },
- onTap = {
- tapped = true
- }
- )
- }
-
- private val allGestures = SuspendingGestureTestUtil {
- detectTapGestures(
- onPress = {
- pressed = true
- try {
- awaitRelease()
- released = true
- } catch (_: GestureCancellationException) {
- canceled = true
- }
- },
- onTap = { tapped = true },
- onLongPress = { longPressed = true },
- onDoubleTap = { doubleTapped = true }
- )
- }
-
- @Before
- fun setup() {
- pressed = false
- released = false
- canceled = false
- tapped = false
- doubleTapped = false
- longPressed = false
- }
-
- /**
- * Clicking in the region should result in the callback being invoked.
- */
- @Test
- fun normalTap() = util.executeInComposition {
- val down = down(5f, 5f)
- assertTrue(down.isConsumed)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
- assertFalse(tapped)
- assertFalse(released)
-
- val up = down.up(50)
- assertTrue(up.isConsumed)
- assertTrue(up.isConsumed)
-
- assertTrue(tapped)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * Clicking in the region should result in the callback being invoked.
- */
- @Test
- fun normalTap_withShortcut() = utilWithShortcut.executeInComposition {
- val down = down(5f, 5f)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
- assertFalse(tapped)
- assertFalse(released)
-
- val up = down.up(50)
- assertTrue(up.isConsumed)
-
- assertTrue(tapped)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * Clicking in the region should result in the callback being invoked.
- */
- @Test
- fun normalTapWithAllGestures() = allGestures.executeInComposition {
- val down = down(5f, 5f)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
-
- val up = down.up(50)
- assertTrue(up.isConsumed)
-
- assertTrue(released)
-
- // we have to wait for the double-tap timeout before we receive an event
-
- assertFalse(tapped)
- assertFalse(doubleTapped)
-
- delay(DoubleTapTimeoutMillis + 10)
-
- assertTrue(tapped)
- assertFalse(doubleTapped)
- }
-
- /**
- * Clicking in the region should result in the callback being invoked.
- */
- @Test
- fun normalDoubleTap() = allGestures.executeInComposition {
- val up = down(5f, 5f)
- .up()
- assertTrue(up.isConsumed)
-
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(tapped)
- assertFalse(doubleTapped)
-
- pressed = false
- released = false
-
- val up2 = down(5f, 5f, 50)
- .up()
- assertTrue(up2.isConsumed)
-
- assertFalse(tapped)
- assertTrue(doubleTapped)
- assertTrue(pressed)
- assertTrue(released)
- }
-
- /**
- * Long press in the region should result in the callback being invoked.
- */
- @Test
- fun normalLongPress() = allGestures.executeInComposition {
- val down = down(5f, 5f)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
- delay(LongPressTimeoutMillis + 10)
-
- assertTrue(longPressed)
-
- val up = down.up(500)
- assertTrue(up.isConsumed)
-
- assertFalse(tapped)
- assertFalse(doubleTapped)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * Pressing in the region, sliding out and then lifting should result in
- * the callback not being invoked
- */
- @Test
- fun tapMiss() = util.executeInComposition {
- val up = down(5f, 5f)
- .moveTo(15f, 15f)
- .up()
-
- assertTrue(pressed)
- assertTrue(canceled)
- assertFalse(released)
- assertFalse(tapped)
- assertFalse(up.isConsumed)
- assertFalse(up.isConsumed)
- }
-
- /**
- * Pressing in the region, sliding out and then lifting should result in
- * the callback not being invoked
- */
- @Test
- fun tapMiss_withShortcut() = utilWithShortcut.executeInComposition {
- val up = down(5f, 5f)
- .moveTo(15f, 15f)
- .up()
-
- assertTrue(pressed)
- assertTrue(canceled)
- assertFalse(released)
- assertFalse(tapped)
- assertFalse(up.isConsumed)
- }
-
- /**
- * Pressing in the region, sliding out and then lifting should result in
- * the callback not being invoked
- */
- @Test
- fun longPressMiss() = allGestures.executeInComposition {
- val pointer = down(5f, 5f)
- .moveTo(15f, 15f)
-
- delay(DoubleTapTimeoutMillis + 10)
- val up = pointer.up()
- assertFalse(up.isConsumed)
-
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- assertFalse(tapped)
- assertFalse(longPressed)
- assertFalse(doubleTapped)
- }
-
- /**
- * Pressing in the region, sliding out and then lifting should result in
- * the callback not being invoked for double-tap
- */
- @Test
- fun doubleTapMiss() = allGestures.executeInComposition {
- val up1 = down(5f, 5f).up()
- assertTrue(up1.isConsumed)
-
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
-
- pressed = false
- released = false
-
- val up2 = down(5f, 5f, 50)
- .moveTo(15f, 15f)
- .up()
-
- assertFalse(up2.isConsumed)
-
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- assertTrue(tapped)
- assertFalse(longPressed)
- assertFalse(doubleTapped)
- }
-
- /**
- * Pressing in the region, sliding out, then back in, then lifting
- * should result the gesture being canceled.
- */
- @Test
- fun tapOutAndIn() = util.executeInComposition {
- val up = down(5f, 5f)
- .moveTo(15f, 15f)
- .moveTo(6f, 6f)
- .up()
-
- assertFalse(tapped)
- assertFalse(up.isConsumed)
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Pressing in the region, sliding out, then back in, then lifting
- * should result the gesture being canceled.
- */
- @Test
- fun tapOutAndIn_withShortcut() = utilWithShortcut.executeInComposition {
- val up = down(5f, 5f)
- .moveTo(15f, 15f)
- .moveTo(6f, 6f)
- .up()
-
- assertFalse(tapped)
- assertFalse(up.isConsumed)
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * After a first tap, a second tap should also be detected.
- */
- @Test
- fun secondTap() = util.executeInComposition {
- down(5f, 5f)
- .up()
-
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
-
- tapped = false
- pressed = false
- released = false
-
- val up2 = down(4f, 4f)
- .up()
- assertTrue(tapped)
- assertTrue(up2.isConsumed)
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * After a first tap, a second tap should also be detected.
- */
- @Test
- fun secondTap_withShortcut() = utilWithShortcut.executeInComposition {
- down(5f, 5f)
- .up()
-
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
-
- tapped = false
- pressed = false
- released = false
-
- val up2 = down(4f, 4f)
- .up()
- assertTrue(tapped)
- assertTrue(up2.isConsumed)
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * Clicking in the region with the up already consumed should result in the callback not
- * being invoked.
- */
- @Test
- fun consumedUpTap() = util.executeInComposition {
- val down = down(5f, 5f)
-
- assertFalse(tapped)
- assertTrue(pressed)
-
- down.up {
- if (pressed != previousPressed) consume()
- }
-
- assertFalse(tapped)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Clicking in the region with the up already consumed should result in the callback not
- * being invoked.
- */
- @Test
- fun consumedUpTap_withShortcut() = utilWithShortcut.executeInComposition {
- val down = down(5f, 5f)
-
- assertFalse(tapped)
- assertTrue(pressed)
-
- down.up {
- if (pressed != previousPressed) consume()
- }
-
- assertFalse(tapped)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Clicking in the region with the motion consumed should result in the callback not
- * being invoked.
- */
- @Test
- fun consumedMotionTap() = util.executeInComposition {
- down(5f, 5f)
- .moveTo(6f, 2f) {
- consume()
- }
- .up(50)
-
- assertFalse(tapped)
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Clicking in the region with the motion consumed should result in the callback not
- * being invoked.
- */
- @Test
- fun consumedMotionTap_withShortcut() = utilWithShortcut.executeInComposition {
- down(5f, 5f)
- .moveTo(6f, 2f) {
- consume()
- }
- .up(50)
-
- assertFalse(tapped)
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- @Test
- fun consumedChange_MotionTap() = util.executeInComposition {
- down(5f, 5f)
- .moveTo(6f, 2f) {
- consume()
- }
- .up(50)
-
- assertFalse(tapped)
- assertTrue(pressed)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Clicking in the region with the up already consumed should result in the callback not
- * being invoked.
- */
- @Test
- fun consumedChange_upTap() = util.executeInComposition {
- val down = down(5f, 5f)
-
- assertFalse(tapped)
- assertTrue(pressed)
-
- down.up {
- consume()
- }
-
- assertFalse(tapped)
- assertFalse(released)
- assertTrue(canceled)
- }
-
- /**
- * Ensure that two-finger taps work.
- */
- @Test
- fun twoFingerTap() = util.executeInComposition {
- val down = down(1f, 1f)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
- pressed = false
-
- val down2 = down(9f, 5f)
- assertFalse(down2.isConsumed)
- assertFalse(down2.isConsumed)
-
- assertFalse(pressed)
-
- val up = down.up()
- assertFalse(up.isConsumed)
- assertFalse(up.isConsumed)
- assertFalse(tapped)
- assertFalse(released)
-
- val up2 = down2.up()
- assertTrue(up2.isConsumed)
- assertTrue(up2.isConsumed)
-
- assertTrue(tapped)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * Ensure that two-finger taps work.
- */
- @Test
- fun twoFingerTap_withShortcut() = utilWithShortcut.executeInComposition {
- val down = down(1f, 1f)
- assertTrue(down.isConsumed)
-
- assertTrue(pressed)
- pressed = false
-
- val down2 = down(9f, 5f)
- assertFalse(down2.isConsumed)
-
- assertFalse(pressed)
-
- val up = down.up()
- assertFalse(up.isConsumed)
- assertFalse(tapped)
- assertFalse(released)
-
- val up2 = down2.up()
- assertTrue(up2.isConsumed)
-
- assertTrue(tapped)
- assertTrue(released)
- assertFalse(canceled)
- }
-
- /**
- * A position change consumption on any finger should cause tap to cancel.
- */
- @Test
- fun twoFingerTapCancel() = util.executeInComposition {
- val down = down(1f, 1f)
-
- assertTrue(pressed)
-
- val down2 = down(9f, 5f)
-
- val up = down.moveTo(5f, 5f) {
- consume()
- }.up()
- assertFalse(up.isConsumed)
-
- assertFalse(tapped)
- assertTrue(canceled)
-
- val up2 = down2.up(50)
- assertFalse(up2.isConsumed)
-
- assertFalse(tapped)
- assertFalse(released)
- }
-
- /**
- * A position change consumption on any finger should cause tap to cancel.
- */
- @Test
- fun twoFingerTapCancel_withShortcut() = utilWithShortcut.executeInComposition {
- val down = down(1f, 1f)
-
- assertTrue(pressed)
-
- val down2 = down(9f, 5f)
-
- val up = down.moveTo(5f, 5f) {
- consume()
- }.up()
- assertFalse(up.isConsumed)
-
- assertFalse(tapped)
- assertTrue(canceled)
-
- val up2 = down2.up(50)
- assertFalse(up2.isConsumed)
-
- assertFalse(tapped)
- assertFalse(released)
- }
-
- /**
- * Detect the second tap as long press.
- */
- @Test
- fun secondTapLongPress() = allGestures.executeInComposition {
- down(5f, 5f).up()
-
- assertTrue(pressed)
- assertTrue(released)
- assertFalse(canceled)
- assertFalse(tapped)
- assertFalse(doubleTapped)
- assertFalse(longPressed)
-
- pressed = false
- released = false
-
- val secondDown = down(5f, 5f, 50)
-
- assertTrue(pressed)
-
- delay(LongPressTimeoutMillis + 10)
-
- assertTrue(tapped)
- assertTrue(longPressed)
- assertFalse(released)
- assertFalse(canceled)
-
- secondDown.up(500)
- assertTrue(released)
- }
-}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt
deleted file mode 100644
index 77c4a91..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt
+++ /dev/null
@@ -1,352 +0,0 @@
-/*
- * Copyright 2019 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.foundation.gestures
-
-import androidx.compose.ui.geometry.Offset
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@RunWith(Parameterized::class)
-class TransformGestureDetectorTest(val panZoomLock: Boolean) {
- companion object {
- @JvmStatic
- @Parameterized.Parameters
- fun parameters() = arrayOf(false, true)
- }
-
- private var centroid = Offset.Zero
- private var panned = false
- private var panAmount = Offset.Zero
- private var rotated = false
- private var rotateAmount = 0f
- private var zoomed = false
- private var zoomAmount = 1f
-
- private val util = SuspendingGestureTestUtil {
- detectTransformGestures(panZoomLock = panZoomLock) { c, pan, gestureZoom, gestureAngle ->
- centroid = c
- if (gestureAngle != 0f) {
- rotated = true
- rotateAmount += gestureAngle
- }
- if (gestureZoom != 1f) {
- zoomed = true
- zoomAmount *= gestureZoom
- }
- if (pan != Offset.Zero) {
- panned = true
- panAmount += pan
- }
- }
- }
-
- @Before
- fun setup() {
- panned = false
- panAmount = Offset.Zero
- rotated = false
- rotateAmount = 0f
- zoomed = false
- zoomAmount = 1f
- }
-
- /**
- * Single finger pan.
- */
- @Test
- fun singleFingerPan() = util.executeInComposition {
- val down = down(5f, 5f)
- assertFalse(down.isConsumed)
-
- assertFalse(panned)
-
- val move1 = down.moveBy(Offset(12.7f, 12.7f))
- assertFalse(move1.isConsumed)
-
- assertFalse(panned)
-
- val move2 = move1.moveBy(Offset(0.1f, 0.1f))
- assertTrue(move2.isConsumed)
-
- assertEquals(17.7f, centroid.x, 0.1f)
- assertEquals(17.7f, centroid.y, 0.1f)
- assertTrue(panned)
- assertFalse(zoomed)
- assertFalse(rotated)
-
- assertTrue(panAmount.getDistance() < 1f)
-
- panAmount = Offset.Zero
- val move3 = move2.moveBy(Offset(1f, 0f))
- assertTrue(move3.isConsumed)
-
- assertEquals(Offset(1f, 0f), panAmount)
-
- move3.up().also { assertFalse(it.isConsumed) }
-
- assertFalse(rotated)
- assertFalse(zoomed)
- }
-
- /**
- * Multi-finger pan
- */
- @Test
- fun multiFingerPanZoom() = util.executeInComposition {
- val downA = down(5f, 5f)
- assertFalse(downA.isConsumed)
-
- val downB = down(25f, 25f)
- assertFalse(downB.isConsumed)
-
- assertFalse(panned)
-
- val moveA1 = downA.moveBy(Offset(12.8f, 12.8f))
- assertFalse(moveA1.isConsumed)
-
- val moveB1 = downB.moveBy(Offset(12.8f, 12.8f))
- // Now we've averaged enough movement
- assertTrue(moveB1.isConsumed)
-
- assertEquals((5f + 25f + 12.8f) / 2f, centroid.x, 0.1f)
- assertEquals((5f + 25f + 12.8f) / 2f, centroid.y, 0.1f)
- assertTrue(panned)
- assertTrue(zoomed)
- assertFalse(rotated)
-
- assertEquals(6.4f, panAmount.x, 0.1f)
- assertEquals(6.4f, panAmount.y, 0.1f)
-
- moveA1.up()
- moveB1.up()
- }
-
- /**
- * 2-pointer zoom
- */
- @Test
- fun zoom2Pointer() = util.executeInComposition {
- val downA = down(5f, 5f)
- assertFalse(downA.isConsumed)
-
- val downB = down(25f, 5f)
- assertFalse(downB.isConsumed)
-
- val moveB1 = downB.moveBy(Offset(35.95f, 0f))
- assertFalse(moveB1.isConsumed)
-
- val moveB2 = moveB1.moveBy(Offset(0.1f, 0f))
- assertTrue(moveB2.isConsumed)
-
- assertTrue(panned)
- assertTrue(zoomed)
- assertFalse(rotated)
-
- // both should be small movements
- assertTrue(panAmount.getDistance() < 1f)
- assertTrue(zoomAmount in 1f..1.1f)
-
- zoomAmount = 1f
- panAmount = Offset.Zero
-
- val moveA1 = downA.moveBy(Offset(-1f, 0f))
- assertTrue(moveA1.isConsumed)
-
- val moveB3 = moveB2.moveBy(Offset(1f, 0f))
- assertTrue(moveB3.isConsumed)
-
- assertEquals(0f, panAmount.x, 0.01f)
- assertEquals(0f, panAmount.y, 0.01f)
-
- assertEquals(48f / 46f, zoomAmount, 0.01f)
-
- moveA1.up()
- moveB3.up()
- }
-
- /**
- * 4-pointer zoom
- */
- @Test
- fun zoom4Pointer() = util.executeInComposition {
- val downA = down(0f, 50f)
- // just get past the touch slop
- val slop1 = downA.moveBy(Offset(-1000f, 0f))
- val slop2 = slop1.moveBy(Offset(1000f, 0f))
-
- panned = false
- panAmount = Offset.Zero
-
- val downB = down(100f, 50f)
- val downC = down(50f, 0f)
- val downD = down(50f, 100f)
-
- val moveA = slop2.moveBy(Offset(-50f, 0f))
- val moveB = downB.moveBy(Offset(50f, 0f))
-
- assertTrue(zoomed)
- assertTrue(panned)
-
- assertEquals(0f, panAmount.x, 0.1f)
- assertEquals(0f, panAmount.y, 0.1f)
- assertEquals(1.5f, zoomAmount, 0.1f)
-
- val moveC = downC.moveBy(Offset(0f, -50f))
- val moveD = downD.moveBy(Offset(0f, 50f))
-
- assertEquals(0f, panAmount.x, 0.1f)
- assertEquals(0f, panAmount.y, 0.1f)
- assertEquals(2f, zoomAmount, 0.1f)
-
- moveA.up()
- moveB.up()
- moveC.up()
- moveD.up()
- }
-
- /**
- * 2 pointer rotation.
- */
- @Test
- fun rotation2Pointer() = util.executeInComposition {
- val downA = down(0f, 50f)
- val downB = down(100f, 50f)
- val moveA = downA.moveBy(Offset(50f, -50f))
- val moveB = downB.moveBy(Offset(-50f, 50f))
-
- // assume some of the above was touch slop
- assertTrue(rotated)
- rotateAmount = 0f
- rotated = false
- zoomAmount = 1f
- panAmount = Offset.Zero
-
- // now do the real rotation:
- val moveA2 = moveA.moveBy(Offset(-50f, 50f))
- val moveB2 = moveB.moveBy(Offset(50f, -50f))
-
- moveA2.up()
- moveB2.up()
-
- assertTrue(rotated)
- assertEquals(-90f, rotateAmount, 0.01f)
- assertEquals(0f, panAmount.x, 0.1f)
- assertEquals(0f, panAmount.y, 0.1f)
- assertEquals(1f, zoomAmount, 0.1f)
- }
-
- /**
- * 2 pointer rotation, with early panning.
- */
- @Test
- fun rotation2PointerLock() = util.executeInComposition {
- val downA = down(0f, 50f)
- // just get past the touch slop with panning
- val slop1 = downA.moveBy(Offset(-1000f, 0f))
- val slop2 = slop1.moveBy(Offset(1000f, 0f))
-
- val downB = down(100f, 50f)
-
- // now do the rotation:
- val moveA2 = slop2.moveBy(Offset(50f, -50f))
- val moveB2 = downB.moveBy(Offset(-50f, 50f))
-
- moveA2.up()
- moveB2.up()
-
- if (panZoomLock) {
- assertFalse(rotated)
- } else {
- assertTrue(rotated)
- assertEquals(90f, rotateAmount, 0.01f)
- }
- assertEquals(0f, panAmount.x, 0.1f)
- assertEquals(0f, panAmount.y, 0.1f)
- assertEquals(1f, zoomAmount, 0.1f)
- }
-
- /**
- * Adding or removing a pointer won't change the current values
- */
- @Test
- fun noChangeOnPointerDownUp() = util.executeInComposition {
- val downA = down(0f, 50f)
- val downB = down(100f, 50f)
- val moveA = downA.moveBy(Offset(50f, -50f))
- val moveB = downB.moveBy(Offset(-50f, 50f))
-
- // now we've gotten past the touch slop
- rotated = false
- panned = false
- zoomed = false
-
- val downC = down(0f, 50f)
-
- assertFalse(rotated)
- assertFalse(panned)
- assertFalse(zoomed)
-
- val downD = down(100f, 50f)
- assertFalse(rotated)
- assertFalse(panned)
- assertFalse(zoomed)
-
- moveA.up()
- moveB.up()
- downC.up()
- downD.up()
-
- assertFalse(rotated)
- assertFalse(panned)
- assertFalse(zoomed)
- }
-
- /**
- * Consuming position during touch slop will cancel the current gesture.
- */
- @Test
- fun touchSlopCancel() = util.executeInComposition {
- down(5f, 5f)
- .moveBy(Offset(50f, 0f)) { consume() }
- .up()
-
- assertFalse(panned)
- assertFalse(zoomed)
- assertFalse(rotated)
- }
-
- /**
- * Consuming position after touch slop will cancel the current gesture.
- */
- @Test
- fun afterTouchSlopCancel() = util.executeInComposition {
- down(5f, 5f)
- .moveBy(Offset(50f, 0f))
- .moveBy(Offset(50f, 0f)) { consume() }
- .up()
-
- assertTrue(panned)
- assertFalse(zoomed)
- assertFalse(rotated)
- assertEquals(50f, panAmount.x, 0.1f)
- }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
deleted file mode 100644
index aa03db7..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2023 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.foundation.text
-
-import androidx.compose.foundation.gestures.SuspendingGestureTestUtil
-import androidx.compose.ui.geometry.Offset
-import com.google.common.truth.Correspondence
-import com.google.common.truth.IterableSubject
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4::class)
-class PointerMoveDetectorTest {
- @Test
- fun whenSimpleMovement_allMovesAreReported() {
- val actualMoves = mutableListOf()
- SuspendingGestureTestUtil {
- detectMoves { actualMoves.add(it) }
- }.executeInComposition {
- down(5f, 5f)
- .moveTo(4f, 4f)
- .moveTo(3f, 3f)
- .moveTo(2f, 2f)
- .moveTo(1f, 1f)
- .up()
-
- assertThat(actualMoves).hasEqualOffsets(
- listOf(
- Offset(4f, 4f),
- Offset(3f, 3f),
- Offset(2f, 2f),
- Offset(1f, 1f),
- )
- )
- }
- }
-
- @Test
- fun whenMultiplePointers_onlyUseFirst() {
- val actualMoves = mutableListOf()
- SuspendingGestureTestUtil {
- detectMoves { actualMoves.add(it) }
- }.executeInComposition {
- var m1 = down(5f, 5f)
- var m2 = down(6f, 6f)
- m1 = m1.moveTo(4f, 4f)
- m2 = m2.moveTo(7f, 7f)
- m1 = m1.moveTo(3f, 3f)
- m2 = m2.moveTo(8f, 8f)
- m1 = m1.moveTo(2f, 2f)
- m2 = m2.moveTo(9f, 9f)
- m1.moveTo(1f, 1f)
- m2.moveTo(10f, 10f)
- m1.up()
- m2.up()
-
- assertThat(actualMoves).hasEqualOffsets(
- listOf(
- Offset(4f, 4f),
- Offset(3f, 3f),
- Offset(2f, 2f),
- Offset(1f, 1f),
- )
- )
- }
- }
-
- @Test
- fun whenMultiplePointers_thenFirstReleases_handOffToNextPointer() {
- val actualMoves = mutableListOf()
- SuspendingGestureTestUtil {
- detectMoves { actualMoves.add(it) }
- }.executeInComposition {
- var m1 = down(5f, 5f) // ignored because not a move
- m1 = m1.moveTo(4f, 4f) // used
- m1 = m1.moveTo(3f, 3f) // used
- var m2 = down(4f, 4f) // ignored because still tracking m1
- m1 = m1.moveTo(2f, 2f) // used
- m2 = m2.moveTo(3f, 3f) // ignored because still tracking m1
- m1.up() // ignored because not a move
- m2.moveTo(2f, 2f) // ignored because equal to the previous used move
- m2.moveTo(1f, 1f) // used
- m2.up() // ignored because not a move
-
- assertThat(actualMoves).hasEqualOffsets(
- listOf(
- Offset(4f, 4f),
- Offset(3f, 3f),
- Offset(2f, 2f),
- Offset(1f, 1f),
- )
- )
- }
- }
-
- private fun IterableSubject.hasEqualOffsets(expectedMoves: List) {
- comparingElementsUsing(offsetCorrespondence)
- .containsExactly(*expectedMoves.toTypedArray())
- .inOrder()
- }
-
- private val offsetCorrespondence: Correspondence = Correspondence.from(
- { o1, o2 -> o1!!.x == o2!!.x && o1.y == o2.y },
- "has the offset of",
- )
-}
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
index f0abfb3..7d94bd5 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
@@ -42,7 +42,10 @@
@Composable
private fun TextItem(text: String, color: Color) {
Row {
- Box(Modifier.size(25.dp).background(color))
+ Box(
+ Modifier
+ .size(25.dp)
+ .background(color))
Spacer(Modifier.width(5.dp))
Text(text, fontSize = 20.sp)
}
@@ -60,6 +63,7 @@
PointerEventType.Enter -> Color.Green
PointerEventType.Exit -> Color.Blue
PointerEventType.Scroll -> Color(0xFF800080) // Purple
+ PointerEventType.Unknown -> Color.White
else -> Color.Black
}
TextItem("$type $value", color)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
index 417ace5..4f5a9d6 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
@@ -32,6 +32,8 @@
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -47,7 +49,7 @@
@get:Rule
val rule = createComposeRule()
- val tag = "Tagged Layout"
+ private val tag = "Tagged Layout"
@Test
fun sendNotANumberDensityInPointerEvents() {
@@ -211,16 +213,36 @@
awaitPointerEvent()
}
}
- })
+ }.testTag(tag)
+ )
}
}
+ // Because the pointer input coroutine scope is created lazily, that is, it won't be
+ // created/triggered until there is a event(tap), we must trigger a tap to instantiate the
+ // pointer input block of code.
+ rule.waitForIdle()
+ rule.onNodeWithTag(tag)
+ .performTouchInput {
+ down(Offset.Zero)
+ moveBy(Offset(1f, 1f))
+ up()
+ }
+
rule.runOnIdle {
assertThat(pointerInputDensities.size).isEqualTo(1)
assertThat(pointerInputDensities.last()).isEqualTo(5f)
density = 9f
}
+ rule.waitForIdle()
+ rule.onNodeWithTag(tag)
+ .performTouchInput {
+ down(Offset.Zero)
+ moveBy(Offset(1f, 1f))
+ up()
+ }
+
rule.runOnIdle {
assertThat(pointerInputDensities.size).isEqualTo(2)
assertThat(pointerInputDensities.last()).isEqualTo(9f)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt
new file mode 100644
index 0000000..887b17a
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 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.input.pointer
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The block of code for a pointer input should be reset if the view configuration changes. This
+ * class tests all those key possibilities.
+ */
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class PointerInputViewConfigurationTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ private val tag = "Tagged Layout"
+
+ @Test
+ fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload1() {
+ compositionLocalViewConfigurationChangeRestartsPointerInput {
+ Modifier.pointerInput(Unit, block = it)
+ }
+ }
+
+ @Test
+ fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload2() {
+ compositionLocalViewConfigurationChangeRestartsPointerInput {
+ Modifier.pointerInput(Unit, Unit, block = it)
+ }
+ }
+
+ @Test
+ fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload3() {
+ compositionLocalViewConfigurationChangeRestartsPointerInput {
+ Modifier.pointerInput(Unit, Unit, Unit, block = it)
+ }
+ }
+
+ private fun compositionLocalViewConfigurationChangeRestartsPointerInput(
+ pointerInput: (block: suspend PointerInputScope.() -> Unit) -> Modifier
+ ) {
+ var viewConfigurationTouchSlop by mutableStateOf(18f)
+
+ val pointerInputViewConfigurations = mutableListOf()
+ rule.setContent {
+ CompositionLocalProvider(
+ LocalViewConfiguration provides TestViewConfiguration(
+ touchSlop = viewConfigurationTouchSlop
+ ),
+ ) {
+ Box(pointerInput {
+ pointerInputViewConfigurations.add(viewConfigurationTouchSlop)
+ awaitPointerEventScope {
+ while (true) {
+ awaitPointerEvent()
+ }
+ }
+ }.testTag(tag)
+ )
+ }
+ }
+
+ // Because the pointer input coroutine scope is created lazily, that is, it won't be
+ // created/triggered until there is a event(tap), we must trigger a tap to instantiate the
+ // pointer input block of code.
+ rule.waitForIdle()
+ rule.onNodeWithTag(tag)
+ .performTouchInput {
+ down(Offset.Zero)
+ moveBy(Offset(1f, 1f))
+ up()
+ }
+
+ rule.runOnIdle {
+ assertThat(pointerInputViewConfigurations.size).isEqualTo(1)
+ assertThat(pointerInputViewConfigurations.last()).isEqualTo(18f)
+ viewConfigurationTouchSlop = 20f
+ }
+
+ rule.waitForIdle()
+ rule.onNodeWithTag(tag)
+ .performTouchInput {
+ down(Offset.Zero)
+ moveBy(Offset(1f, 1f))
+ up()
+ }
+
+ rule.runOnIdle {
+ assertThat(pointerInputViewConfigurations.size).isEqualTo(2)
+ assertThat(pointerInputViewConfigurations.last()).isEqualTo(20f)
+ }
+ }
+}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
index d5b715f..bfda21fb 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
@@ -16,37 +16,34 @@
package androidx.compose.ui.input.pointer
-import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.ValueElement
+import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
-import androidx.compose.ui.test.TestActivity
+import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.IntSize
-import androidx.lifecycle.Lifecycle
-import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
+import androidx.test.filters.MediumTest
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.CompletableDeferred
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
@@ -56,66 +53,86 @@
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
+import org.junit.Rule
@SmallTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class SuspendingPointerInputFilterTest {
+ @get:Rule
+ val rule = createComposeRule()
+
@After
fun after() {
// some tests may set this
isDebugInspectorInfoEnabled = false
}
- private fun runTestUnconfined(test: suspend TestScope.() -> Unit) =
- runTest(UnconfinedTestDispatcher()) {
- test()
- }
-
@Test
- fun testAwaitSingleEvent(): Unit = runTestUnconfined {
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
-
- val result = CompletableDeferred()
- launch {
- with(filter) {
- awaitPointerEventScope {
- result.complete(awaitPointerEvent())
- }
- }
- }
-
+ @MediumTest
+ fun testAwaitSingleEvent() {
+ val latch = CountDownLatch(1)
val emitter = PointerInputChangeEmitter()
val expectedChange = emitter.nextChange(Offset(5f, 5f))
- filter.onPointerEvent(
- expectedChange.toPointerEvent(),
- PointerEventPass.Main,
- IntSize(10, 10)
- )
+ // Used to manually trigger a PointerEvent created from our PointerInputChange.
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+ var returnedChange: PointerEvent? = null
- val receivedEvent = withTimeout(200) {
- result.await()
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+ awaitPointerEventScope {
+ returnedChange = awaitPointerEvent()
+ latch.countDown()
+ }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
}
- assertEquals(expectedChange, receivedEvent.firstChange)
+ rule.runOnIdle {
+ testSuspendPointerInputModifierNodeElement?.let {
+ it.pointerInputModifierNode?.onPointerEvent(
+ expectedChange.toPointerEvent(),
+ PointerEventPass.Main,
+ IntSize(10, 10)
+ )
+ }
+ }
+
+ rule.runOnIdle {
+ assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
+ assertEquals(expectedChange, returnedChange?.firstChange)
+ }
}
@Test
- fun testAwaitSeveralEvents(): Unit = runTestUnconfined {
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
+ @MediumTest
+ fun testAwaitSeveralEvents() {
+ val latch = CountDownLatch(3)
val results = Channel(Channel.UNLIMITED)
- launch {
- with(filter) {
+
+ // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
awaitPointerEventScope {
repeat(3) {
results.trySend(awaitPointerEvent())
+ latch.countDown()
}
results.close()
}
- }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
}
val emitter = PointerInputChangeEmitter()
@@ -126,36 +143,62 @@
)
val bounds = IntSize(20, 20)
- expected.forEach {
- filter.onPointerEvent(it.toPointerEvent(), PointerEventPass.Main, bounds)
- }
- val received = withTimeout(200) {
- results.receiveAsFlow()
- .map { it.firstChange }
- .toList()
+
+ rule.runOnIdle {
+ expected.forEach { pointerInputChange ->
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerInputChange.toPointerEvent(),
+ PointerEventPass.Main,
+ bounds
+ )
+ }
+ }
}
- assertEquals(expected, received)
+ rule.runOnIdle {
+ assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
+
+ runTest {
+ val received = withTimeout(200) {
+ results.receiveAsFlow()
+ .map { it.firstChange }
+ .toList()
+ }
+ assertEquals(expected, received)
+ }
+ }
}
@Test
- fun testSyntheticCancelEvent(): Unit = runTestUnconfined {
+ @MediumTest
+ fun testSyntheticCancelEvent() {
var currentEventAtEnd: PointerEvent? = null
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
+ val latch = CountDownLatch(3)
val results = Channel(Channel.UNLIMITED)
- launch {
- with(filter) {
+
+ // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
awaitPointerEventScope {
try {
repeat(3) {
results.trySend(awaitPointerEvent())
+ latch.countDown()
}
results.close()
} finally {
currentEventAtEnd = currentEvent
}
}
- }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
}
val bounds = IntSize(50, 50)
@@ -174,7 +217,9 @@
emitter2.nextChange(Offset(10f, 10f), down = false)
)
),
- // Synthetic cancel should look like this;
+ // Synthetic cancel should look like this (Note: this specific event isn't ever
+ // triggered directly, it's just for reference so you know what onCancelPointerInput()
+ // triggers).
// Both pointers are there, but only the with the pressed = true is changed to false,
// and the down change is consumed.
PointerEvent(
@@ -203,37 +248,79 @@
)
)
- expectedEvents.take(expectedEvents.size - 1).forEach {
- filter.onPointerEvent(it, PointerEventPass.Initial, bounds)
- filter.onPointerEvent(it, PointerEventPass.Main, bounds)
- filter.onPointerEvent(it, PointerEventPass.Final, bounds)
- }
- filter.onCancel()
+ rule.runOnIdle {
+ expectedEvents.take(expectedEvents.size - 1).forEach { pointerEvent ->
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ // Initial
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Initial,
+ bounds
+ )
- val received = withTimeout(200) {
- results.receiveAsFlow().toList()
+ // Main
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Main,
+ bounds
+ )
+
+ // Final
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Final,
+ bounds
+ )
+ }
+ }
+
+ // Triggers cancel event
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ testerNodeElement.pointerInputModifierNode?.onCancelPointerInput()
+ }
}
- assertThat(expectedEvents).hasSize(received.size)
+ // Checks events triggered are the correct ones
+ rule.runOnIdle {
+ assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
- expectedEvents.forEachIndexed { index, expectedEvent ->
- val actualEvent = received[index]
- PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+ runTest {
+ val received = withTimeout(200) {
+ results.receiveAsFlow().toList()
+ }
+
+ assertThat(expectedEvents).hasSize(received.size)
+
+ expectedEvents.forEachIndexed { index, expectedEvent ->
+ val actualEvent = received[index]
+ PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+ }
+ assertThat(currentEventAtEnd).isNotNull()
+ PointerEventSubject.assertThat(currentEventAtEnd!!)
+ .isStructurallyEqualTo(expectedEvents.last())
+ }
}
- assertThat(currentEventAtEnd).isNotNull()
- PointerEventSubject.assertThat(currentEventAtEnd!!)
- .isStructurallyEqualTo(expectedEvents.last())
}
@Test
- fun testNoSyntheticCancelEventWhenPressIsFalse(): Unit = runTestUnconfined {
+ @LargeTest
+ fun testNoSyntheticCancelEventWhenPressIsFalse() {
var currentEventAtEnd: PointerEvent? = null
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
val results = Channel(Channel.UNLIMITED)
- launch {
- with(filter) {
+
+ // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
awaitPointerEventScope {
try {
+ // NOTE: This will never trigger 3 times. There are only two events
+ // triggered followed by a onCancelPointerInput() call which doesn't trigger
+ // an event because the previous event has down (press) set to false, so we
+ // will always get an exception thrown with the last repeat's timeout
+ // (we expect this).
repeat(3) {
withTimeout(200) {
results.trySend(awaitPointerEvent())
@@ -244,154 +331,383 @@
results.close()
}
}
- }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
}
val bounds = IntSize(50, 50)
val emitter1 = PointerInputChangeEmitter(0)
val emitter2 = PointerInputChangeEmitter(1)
- val expectedEvents = listOf(
+ val twoExpectedEvents = listOf(
PointerEvent(
listOf(
emitter1.nextChange(Offset(5f, 5f)),
emitter2.nextChange(Offset(10f, 10f))
)
),
+ // Pointer event changes don't have any pressed pointers!
PointerEvent(
listOf(
emitter1.nextChange(Offset(6f, 6f), down = false),
emitter2.nextChange(Offset(10f, 10f), down = false)
)
)
- // Unlike when a pointer is down, there is no cancel event sent
- // when there aren't any pressed pointers. There's no event stream to cancel.
)
- expectedEvents.forEach {
- filter.onPointerEvent(it, PointerEventPass.Initial, bounds)
- filter.onPointerEvent(it, PointerEventPass.Main, bounds)
- filter.onPointerEvent(it, PointerEventPass.Final, bounds)
- }
- filter.onCancel()
+ rule.runOnIdle {
+ twoExpectedEvents.forEach { pointerEvent ->
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ // Initial
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Initial,
+ bounds
+ )
- withTimeout(400) {
- while (!results.isClosedForSend) {
- yield()
- }
- }
+ // Main
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Main,
+ bounds
+ )
- val received = results.receiveAsFlow().toList()
-
- assertThat(received).hasSize(expectedEvents.size)
-
- expectedEvents.forEachIndexed { index, expectedEvent ->
- val actualEvent = received[index]
- PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
- }
- assertThat(currentEventAtEnd).isNotNull()
- PointerEventSubject.assertThat(currentEventAtEnd!!)
- .isStructurallyEqualTo(expectedEvents.last())
- }
-
- @Test
- fun testCancelledHandlerBlock() = runTestUnconfined {
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
- val counter = TestCounter()
- val handler = launch {
- with(filter) {
- try {
- awaitPointerEventScope {
- try {
- counter.expect(1, "about to call awaitPointerEvent")
- awaitPointerEvent()
- fail("awaitPointerEvent returned; should have thrown for cancel")
- } finally {
- counter.expect(3, "inner finally block running")
- }
- }
- } finally {
- counter.expect(4, "outer finally block running; inner finally should have run")
- }
- }
- }
-
- counter.expect(2, "before cancelling handler; awaitPointerEvent should be suspended")
- handler.cancel()
- counter.expect(5, "after cancelling; finally blocks should have run")
- }
-
- @Test
- fun testInspectorValue() = runBlocking {
- isDebugInspectorInfoEnabled = true
- val block: suspend PointerInputScope.() -> Unit = {}
- val modifier = Modifier.pointerInput(Unit, block) as InspectableValue
-
- assertThat(modifier.nameFallback).isEqualTo("pointerInput")
- assertThat(modifier.valueOverride).isNull()
- assertThat(modifier.inspectableElements.asIterable()).containsExactly(
- ValueElement("key1", Unit),
- ValueElement("block", block)
- )
- }
-
- @Test
- @LargeTest
- fun testRestartPointerInput() = runBlocking {
- var toAdd by mutableStateOf("initial")
- val result = mutableListOf()
- val latch = CountDownLatch(2)
- ActivityScenario.launch(TestActivity::class.java).use { scenario ->
- scenario.moveToState(Lifecycle.State.CREATED)
- scenario.onActivity {
- it.setContent {
- // Read the value in composition to change the lambda capture below
- val toCapture = toAdd
- Box(
- Modifier.pointerInput(toCapture) {
- result += toCapture
- latch.countDown()
- suspendCancellableCoroutine {}
- }
+ // Final
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ pointerEvent,
+ PointerEventPass.Final,
+ bounds
)
}
}
- scenario.moveToState(Lifecycle.State.STARTED)
- Snapshot.withMutableSnapshot {
- toAdd = "secondary"
+
+ // Manually triggers cancel event.
+ // Note: This will not trigger an event in the customPointerInput block because the
+ // previous events don't have any pressed pointers.
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ testerNodeElement.pointerInputModifierNode?.onCancelPointerInput()
}
- assertTrue("waiting for relaunch timed out", latch.await(3, TimeUnit.SECONDS))
- assertEquals(
- listOf("initial", "secondary"),
- result
+ }
+
+ rule.mainClock.advanceTimeBy(1000)
+
+ rule.runOnIdle {
+ runTest {
+ withTimeout(400) {
+ while (!results.isClosedForSend) {
+ yield()
+ }
+ }
+
+ val received = results.receiveAsFlow().toList()
+
+ assertThat(received).hasSize(twoExpectedEvents.size)
+
+ twoExpectedEvents.forEachIndexed { index, expectedEvent ->
+ val actualEvent = received[index]
+ PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+ }
+ assertThat(currentEventAtEnd).isNotNull()
+ PointerEventSubject.assertThat(currentEventAtEnd!!)
+ .isStructurallyEqualTo(twoExpectedEvents.last())
+ }
+ }
+ }
+
+ @Test
+ @MediumTest
+ fun testCancelledHandlerBlock() {
+ val counter = TestCounter()
+
+ // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+ try {
+ awaitPointerEventScope {
+ try {
+ counter.expect(3, "about to call awaitPointerEvent")
+
+ // With only one event triggered, this will stay stuck in the repeat
+ // block until the Job is cancelled via
+ // SuspendPointerInputModifierNode.resetHandling()
+ repeat(2) {
+ awaitPointerEvent()
+ counter.expect(
+ 4,
+ "One and only pointer event triggered to create Job."
+ )
+ }
+
+ fail("awaitPointerEvent returned; should have thrown for cancel")
+ } finally {
+ counter.expect(6, "inner finally block running")
+ }
+ }
+ } finally {
+ counter.expect(7, "outer finally block running; inner " +
+ "finally should have run")
+ }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
+ }
+
+ val emitter = PointerInputChangeEmitter()
+ val singleEvent = emitter.nextChange(Offset(5f, 5f))
+ val singleEventBounds = IntSize(20, 20)
+
+ rule.runOnIdle {
+ counter.expect(
+ 1,
+ "Job to handle pointer input not created yet; awaitPointerEvent should " +
+ "be suspended"
+ )
+
+ testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+ counter.expect(
+ 2,
+ "Trigger pointer input event to create Job for handing handle pointer" +
+ " input (done lazily in SuspendPointerInputModifierNode)."
+ )
+
+ testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+ singleEvent.toPointerEvent(),
+ PointerEventPass.Main,
+ singleEventBounds
+ )
+ }
+
+ counter.expect(5, "before cancelling handler; awaitPointerEvent " +
+ "should be suspended")
+
+ // Cancels Job that manages pointer input events in SuspendPointerInputModifierNode.
+ testSuspendPointerInputModifierNodeElement?.resetsPointerInputBlockHandler()
+ counter.expect(8, "after cancelling; finally blocks should have run")
+ }
+ }
+
+ @Test
+ @MediumTest
+ fun testInspectorValue() {
+ isDebugInspectorInfoEnabled = true
+
+ rule.setContent {
+ val block: suspend PointerInputScope.() -> Unit = {}
+ val modifier =
+ Modifier.pointerInput(Unit, block) as SuspendPointerInputModifierNodeElement
+
+ assertThat(modifier.nameFallback).isEqualTo("pointerInput")
+ assertThat(modifier.valueOverride).isNull()
+ assertThat(modifier.inspectableElements.asIterable()).containsExactly(
+ ValueElement("key1", Unit),
+ ValueElement("key2", null),
+ ValueElement("keys", null),
+ ValueElement("block", block)
)
}
}
- @Test(expected = PointerEventTimeoutCancellationException::class)
- fun testWithTimeout() = runTestUnconfined {
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
- filter.coroutineScope = this
- with(filter) {
- awaitPointerEventScope {
- withTimeout(10) {
- awaitPointerEvent()
- }
+ @Test
+ @MediumTest
+ fun testRestartPointerInputWithTouchEvent() {
+ val emitter = PointerInputChangeEmitter()
+ val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+ // Used to manually trigger a PointerEvent created from our PointerInputChange.
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ var forceRecompositionCount by mutableStateOf(0)
+ var compositionCount = 0
+ var pointerInputBlockExecutionCount = 0
+
+ rule.setContent {
+ // Read the value in composition to change the lambda capture below
+ val toCapture = forceRecompositionCount
+ compositionCount++
+
+ testSuspendPointerInputModifierNodeElement =
+ Modifier.customTestingPointerInput(toCapture) {
+ // pointerInput now lazily executes this block of code meaning it won't be
+ // executed until an actual event happens.
+ pointerInputBlockExecutionCount++
+ suspendCancellableCoroutine {}
+ } as TestSuspendPointerInputModifierNodeElement
+ Box(modifier = testSuspendPointerInputModifierNodeElement!!)
+ }
+
+ forceRecompositionCount = 1
+
+ rule.runOnIdle {
+ // Triggers first and only event (and launches coroutine).
+ // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+ // will not be launched until the first event is triggered which is what we do here.
+ testSuspendPointerInputModifierNodeElement?.let {
+ it.pointerInputModifierNode?.onPointerEvent(
+ expectedChange.toPointerEvent(),
+ PointerEventPass.Main,
+ IntSize(5, 5)
+ )
}
}
+
+ rule.runOnIdle {
+ assertEquals(compositionCount, 2)
+ // One pointer input event, should have triggered one execution.
+ assertEquals(pointerInputBlockExecutionCount, 1)
+ }
}
@Test
- fun testWithTimeoutOrNull() = runTestUnconfined {
- val filter = SuspendingPointerInputFilter(TestViewConfiguration())
- filter.coroutineScope = this
- val result: PointerEvent? = with(filter) {
- awaitPointerEventScope {
- withTimeoutOrNull(10) {
- awaitPointerEvent()
+ @MediumTest
+ fun testRestartPointerInputWithNoTouchEvents() {
+ var forceRecompositionCount by mutableStateOf(0)
+ var compositionCount = 0
+ var pointerInputBlockExecutionCount = 0
+
+ rule.setContent {
+ // Read the value in composition to change the lambda capture below
+ val toCapture = forceRecompositionCount
+ compositionCount++
+ Box(
+ Modifier.pointerInput(toCapture) {
+ // pointerInput now lazily executes this block of code meaning it won't be
+ // executed until an actual event happens.
+ pointerInputBlockExecutionCount++
+ suspendCancellableCoroutine {}
}
+ )
+ }
+
+ forceRecompositionCount = 1
+
+ rule.runOnIdle {
+ assertEquals(compositionCount, 2)
+ // No pointer input events, no block executions.
+ assertEquals(pointerInputBlockExecutionCount, 0)
+ }
+ }
+
+ @Test
+ @LargeTest
+ fun testWithTimeout() {
+ val latch = CountDownLatch(1)
+ val emitter = PointerInputChangeEmitter()
+ val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+ // Used to manually trigger a PointerEvent created from our PointerInputChange.
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+ awaitPointerEventScope {
+ try {
+ // Handles first event (needed to trigger the creation of the coroutine
+ // since it is lazily created).
+ awaitPointerEvent()
+
+ // Times out waiting for second event (no second event is triggered in this
+ // test).
+ withTimeout(10) {
+ awaitPointerEvent()
+ }
+ } catch (exception: Exception) {
+ assertThat(exception)
+ .isInstanceOf(PointerEventTimeoutCancellationException::class.java)
+ latch.countDown()
+ }
+ }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(modifier = testSuspendPointerInputModifierNodeElement!!)
+ }
+
+ rule.runOnIdle {
+ // Triggers first event (and launches coroutine).
+ // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+ // will not be launched until the first event is triggered which is what we do here.
+ testSuspendPointerInputModifierNodeElement?.let {
+ it.pointerInputModifierNode?.onPointerEvent(
+ expectedChange.toPointerEvent(),
+ PointerEventPass.Main,
+ IntSize(5, 5)
+ )
}
}
- assertThat(result).isNull()
+
+ rule.mainClock.advanceTimeBy(1000)
+
+ rule.runOnIdle {
+ assertTrue(latch.await(2, TimeUnit.SECONDS))
+ }
+ }
+
+ @Test
+ @LargeTest
+ fun testWithTimeoutOrNull() {
+ val emitter = PointerInputChangeEmitter()
+ val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+ // Sets an empty default (if not updated to null after call (expected), it will fail).
+ var resultOfTimeoutOrNull: PointerEvent? = PointerEvent(listOf())
+
+ // Used to manually trigger a PointerEvent created from our PointerInputChange.
+ var testSuspendPointerInputModifierNodeElement:
+ TestSuspendPointerInputModifierNodeElement? = null
+
+ rule.setContent {
+ testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+ awaitPointerEventScope {
+ try {
+ // Handles first event (needed to trigger the creation of the coroutine
+ // since it is lazily created).
+ awaitPointerEvent()
+
+ // Times out waiting for second event (no second event is triggered in this
+ // test).
+ resultOfTimeoutOrNull = withTimeoutOrNull(10) {
+ awaitPointerEvent()
+ }
+ } catch (exception: Exception) {
+ // An exception should not be raised in this test, but, just in case one is,
+ // we want to verify it isn't the one withTimeout will usually raise.
+ assertThat(exception)
+ .isNotInstanceOf(PointerEventTimeoutCancellationException::class.java)
+ }
+ }
+ } as TestSuspendPointerInputModifierNodeElement
+
+ Box(
+ modifier = testSuspendPointerInputModifierNodeElement!!
+ )
+ }
+
+ rule.runOnIdle {
+ // Triggers first event (and launches coroutine).
+ // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+ // will not be launched until the first event is triggered which is what we do here.
+ testSuspendPointerInputModifierNodeElement?.let {
+ it.pointerInputModifierNode?.onPointerEvent(
+ expectedChange.toPointerEvent(),
+ PointerEventPass.Main,
+ IntSize(5, 5)
+ )
+ }
+ }
+
+ rule.mainClock.advanceTimeBy(1000)
+
+ rule.runOnIdle {
+ assertThat(resultOfTimeoutOrNull).isNull()
+ }
}
}
@@ -438,3 +754,76 @@
count = expected
}
}
+
+// Customized version of [Modifier.pointerInput] that uses the customized version of the
+// [SuspendPointerInputModifierNodeElement] class below (it allows us to manually trigger
+// [PointerEvent] events.
+internal fun Modifier.customTestingPointerInput(
+ key1: Any?,
+ block: suspend PointerInputScope.() -> Unit
+): Modifier = this then TestSuspendPointerInputModifierNodeElement(
+ key1 = key1,
+ block = block
+)
+
+// Matches [SuspendPointerInputModifierNodeElement] implementation but maintains a reference to a
+// [SuspendPointerInputModifierNode], so we can manually trigger [PointerEvent] events.
+@OptIn(ExperimentalComposeUiApi::class)
+internal class TestSuspendPointerInputModifierNodeElement(
+ val key1: Any? = null,
+ val key2: Any? = null,
+ val keys: Array? = null,
+ val block: suspend PointerInputScope.() -> Unit
+) : ModifierNodeElement() {
+ private var suspendPointerInputModifierNode: SuspendPointerInputModifierNode? = null
+ var pointerInputModifierNode: PointerInputModifierNode? = null
+
+ override fun InspectorInfo.inspectableProperties() {
+ debugInspectorInfo {
+ name = "pointerInput"
+ properties["key1"] = key1
+ properties["key2"] = key2
+ properties["keys"] = keys
+ properties["block"] = block
+ }
+ }
+
+ override fun create(): SuspendPointerInputModifierNode {
+ suspendPointerInputModifierNode = SuspendPointerInputModifierNode(block)
+ pointerInputModifierNode = suspendPointerInputModifierNode
+ return suspendPointerInputModifierNode as SuspendPointerInputModifierNode
+ }
+
+ override fun update(node: SuspendPointerInputModifierNode): SuspendPointerInputModifierNode {
+ node.block = block
+ suspendPointerInputModifierNode = node
+ pointerInputModifierNode = suspendPointerInputModifierNode
+ return suspendPointerInputModifierNode as SuspendPointerInputModifierNode
+ }
+
+ // Cancels Job that manages pointer input events in SuspendPointerInputModifierNode.
+ fun resetsPointerInputBlockHandler() {
+ suspendPointerInputModifierNode?.resetBlock()
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SuspendPointerInputModifierNodeElement) return false
+ if (key1 != other.key1) return false
+ if (key2 != other.key2) return false
+ if (keys != null) {
+ if (other.keys == null) return false
+ if (!keys.contentEquals(other.keys)) return false
+ } else if (other.keys != null) return false
+ if (block != other.block) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = key1?.hashCode() ?: 0
+ result = 31 * result + (key2?.hashCode() ?: 0)
+ result = 31 * result + (keys?.contentHashCode() ?: 0)
+ result = 31 * result + block.hashCode()
+ return result
+ }
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
index 3e001e7..b7b614a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
@@ -169,7 +169,6 @@
private set
private var scope: CoroutineScope? = null
- // CoroutineScope(baseContext + Job(parent = baseContext[Job]))
val coroutineScope: CoroutineScope
get() = scope ?: CoroutineScope(
requireOwner().coroutineContext +
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
index e238fc0..3c7f48b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
@@ -16,18 +16,13 @@
package androidx.compose.ui.input.pointer
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collection.mutableVectorOf
-import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
import androidx.compose.ui.fastMapNotNull
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
-import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.synchronized
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
@@ -43,13 +38,16 @@
import kotlin.math.max
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import androidx.compose.ui.internal.JvmDefaultWithCompatibility
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.node.requireLayoutNode
+import androidx.compose.ui.platform.InspectorInfo
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
/**
* Receiver scope for awaiting pointer events in a call to
@@ -229,22 +227,10 @@
fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
- inspectorInfo = debugInspectorInfo {
- name = "pointerInput"
- properties["key1"] = key1
- properties["block"] = block
- }
-) {
- val density = LocalDensity.current
- val viewConfiguration = LocalViewConfiguration.current
- remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
- LaunchedEffect(filter, key1) {
- filter.coroutineScope = this
- filter.block()
- }
- }
-}
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+ key1 = key1,
+ block = block
+)
/**
* Create a modifier for processing pointer input within the region of the modified element.
@@ -276,23 +262,11 @@
key1: Any?,
key2: Any?,
block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
- inspectorInfo = debugInspectorInfo {
- name = "pointerInput"
- properties["key1"] = key1
- properties["key2"] = key2
- properties["block"] = block
- }
-) {
- val density = LocalDensity.current
- val viewConfiguration = LocalViewConfiguration.current
- remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
- LaunchedEffect(filter, key1, key2) {
- filter.coroutineScope = this
- filter.block()
- }
- }
-}
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+ key1 = key1,
+ key2 = key2,
+ block = block
+)
/**
* Create a modifier for processing pointer input within the region of the modified element.
@@ -322,20 +296,54 @@
fun Modifier.pointerInput(
vararg keys: Any?,
block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
- inspectorInfo = debugInspectorInfo {
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+ keys = keys,
+ block = block
+)
+
+@OptIn(ExperimentalComposeUiApi::class)
+internal class SuspendPointerInputModifierNodeElement(
+ val key1: Any? = null,
+ val key2: Any? = null,
+ val keys: Array? = null,
+ val block: suspend PointerInputScope.() -> Unit
+) : ModifierNodeElement() {
+ override fun InspectorInfo.inspectableProperties() {
name = "pointerInput"
+ properties["key1"] = key1
+ properties["key2"] = key2
properties["keys"] = keys
properties["block"] = block
}
-) {
- val density = LocalDensity.current
- val viewConfiguration = LocalViewConfiguration.current
- remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
- LaunchedEffect(filter, *keys) {
- filter.coroutineScope = this
- filter.block()
- }
+
+ override fun create(): SuspendPointerInputModifierNode {
+ return SuspendPointerInputModifierNode(block)
+ }
+
+ override fun update(node: SuspendPointerInputModifierNode): SuspendPointerInputModifierNode {
+ node.block = block
+ return node
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SuspendPointerInputModifierNodeElement) return false
+
+ if (key1 != other.key1) return false
+ if (key2 != other.key2) return false
+ if (keys != null) {
+ if (other.keys == null) return false
+ if (!keys.contentEquals(other.keys)) return false
+ } else if (other.keys != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = key1?.hashCode() ?: 0
+ result = 31 * result + (key2?.hashCode() ?: 0)
+ result = 31 * result + (keys?.contentHashCode() ?: 0)
+ return result
}
}
@@ -343,29 +351,44 @@
/**
* Implementation notes:
- * This class does a lot of lifting. It is both a [PointerInputModifier] and that modifier's
- * own [pointerInputFilter]. It is returned by way of a [Modifier.composed] from
- * the [Modifier.pointerInput] builder and is always 1-1 with an instance of application to
- * a LayoutNode.
+ * This class does a lot of lifting. [PointerInputModifierNode] receives, interprets, and, consumes
+ * [PointerInputChange]s while the state (and the coroutineScope used to execute [block]) is
+ * retained in [Modifier.Node].
*
- * [SuspendingPointerInputFilter] implements the [PointerInputScope] used to offer the
- * [Modifier.pointerInput] DSL and carries the [Density] from [LocalDensity] at the point of
- * the modifier's materialization. Even if this value were returned to the [PointerInputFilter]
- * callbacks, we would still need the value at composition time in order for [Modifier.pointerInput]
- * to begin its internal [LaunchedEffect] for the provided code block.
+ * [SuspendPointerInputModifierNode] implements the [PointerInputScope] used to offer the
+ * [Modifier.pointerInput] DSL and provides the [Density] from [LocalDensity] lazily from the
+ * layout node when it is needed.
+ *
+ * Note: The coroutine that executes the passed block for listening to events is launched lazily
+ * when the first event is fired (making it more efficient) and is cancelled via resetHandling()
+ * when
*/
-// TODO: Suppressing deprecation for synchronized; need to move to atomicfu wrapper
-@Suppress("DEPRECATION_ERROR")
-internal class SuspendingPointerInputFilter(
- override val viewConfiguration: ViewConfiguration,
- density: Density = Density(1f)
-) : PointerInputFilter(),
- PointerInputModifier,
- PointerInputScope,
- Density by density {
+@OptIn(ExperimentalComposeUiApi::class)
+internal class SuspendPointerInputModifierNode(
+ block: suspend PointerInputScope.() -> Unit
+) : Modifier.Node(), PointerInputModifierNode, PointerInputScope, Density {
- override val pointerInputFilter: PointerInputFilter
- get() = this
+ var block = block
+ set(value) {
+ resetBlock()
+ field = value
+ }
+
+ override val density: Float
+ get() = requireLayoutNode().density.density
+
+ override val fontScale: Float
+ get() = requireLayoutNode().density.fontScale
+
+ override val viewConfiguration
+ get() = requireLayoutNode().viewConfiguration
+
+ override val size: IntSize
+ get() = boundsSize
+
+ // The code block passed in as a parameter to handle pointer input events is now executed lazily
+ // when the first event fires. This job indicates that pointer input handler job is running.
+ private var pointerInputJob: Job? = null
private var currentEvent: PointerEvent = EmptyPointerEvent
@@ -373,7 +396,8 @@
* Actively registered input handlers from currently ongoing calls to [awaitPointerEventScope].
* Must use `synchronized(pointerHandlers)` to access.
*/
- private val pointerHandlers = mutableVectorOf>()
+ private val pointerHandlers =
+ mutableVectorOf>()
/**
* Scratch list for dispatching to handlers for a particular phase.
@@ -381,7 +405,8 @@
* resumed continuations may add/remove handlers without affecting the current dispatch pass.
* Must only access on the UI thread.
*/
- private val dispatchingPointerHandlers = mutableVectorOf>()
+ private val dispatchingPointerHandlers =
+ mutableVectorOf>()
/**
* The last pointer event we saw where at least one pointer was currently down; null otherwise.
@@ -398,12 +423,6 @@
*/
private var boundsSize: IntSize = IntSize.Zero
- /**
- * This will be changed immediately on launching, but I always want it to be non-null.
- */
- @OptIn(DelicateCoroutinesApi::class)
- var coroutineScope: CoroutineScope = GlobalScope
-
override val extendedTouchPadding: Size
get() {
val minimumTouchTargetSize = viewConfiguration.minimumTouchTargetSize.toSize()
@@ -415,6 +434,27 @@
override var interceptOutOfBoundsChildEvents: Boolean = false
+ override fun onDetach() {
+ resetBlock()
+ super.onDetach()
+ }
+
+ /**
+ * This cancels the existing coroutine and essentially resets the block's execution. Note, the
+ * block still executes lazily, meaning nothing will be done until a new event comes in.
+ * More details: This is triggered from a LayoutNode if the Density or ViewConfiguration change
+ * (in an older implementation using composed, these values were used as keys so it would reset
+ * everything when either change, we do that manually now through this function). It is also
+ * used for testing.
+ */
+ fun resetBlock() {
+ val localJob = pointerInputJob
+ if (localJob != null) {
+ localJob.cancel(CancellationException())
+ pointerInputJob = null
+ }
+ }
+
/**
* Snapshot the current [pointerHandlers] and run [block] on each one.
* May not be called reentrant or concurrent with itself.
@@ -426,7 +466,7 @@
*/
private inline fun forEachCurrentPointerHandler(
pass: PointerEventPass,
- block: (PointerEventHandlerCoroutine<*>) -> Unit
+ block: (SuspendPointerInputModifierNode.PointerEventHandlerCoroutine<*>) -> Unit
) {
// Copy handlers to avoid mutating the collection during dispatch
synchronized(pointerHandlers) {
@@ -436,6 +476,7 @@
when (pass) {
PointerEventPass.Initial, PointerEventPass.Final ->
dispatchingPointerHandlers.forEach(block)
+
PointerEventPass.Main ->
dispatchingPointerHandlers.forEachReversed(block)
}
@@ -466,6 +507,13 @@
if (pass == PointerEventPass.Initial) {
currentEvent = pointerEvent
}
+
+ // Coroutine lazily launches when first event comes in.
+ if (pointerInputJob == null) {
+ // 'start = CoroutineStart.UNDISPATCHED' required so handler doesn't miss first event.
+ pointerInputJob = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { block() }
+ }
+
dispatchPointerEvent(pointerEvent, pass)
lastPointerEvent = pointerEvent.takeIf { event ->
@@ -473,8 +521,7 @@
}
}
- @OptIn(ExperimentalComposeUiApi::class)
- override fun onCancel() {
+ override fun onCancelPointerInput() {
// Synthesize a cancel event for whatever state we previously saw, if one is applicable.
// A cancel event is one where all previously down pointers are now up, the change in
// down-ness is consumed. Any pointers that were previously hovering are left unchanged.
@@ -506,6 +553,9 @@
dispatchPointerEvent(cancelEvent, PointerEventPass.Final)
lastPointerEvent = null
+
+ // Cancels existing coroutine (Job) handling events.
+ resetBlock()
}
override suspend fun awaitPointerEventScope(
@@ -546,18 +596,18 @@
*/
private inner class PointerEventHandlerCoroutine(
private val completion: Continuation,
- ) : AwaitPointerEventScope, Density by this@SuspendingPointerInputFilter, Continuation {
+ ) : AwaitPointerEventScope, Density by this@SuspendPointerInputModifierNode, Continuation {
private var pointerAwaiter: CancellableContinuation? = null
private var awaitPass: PointerEventPass = PointerEventPass.Main
override val currentEvent: PointerEvent
- get() = [email protected]
+ get() = [email protected]
override val size: IntSize
- get() = [email protected]
+ get() = [email protected]
override val viewConfiguration: ViewConfiguration
- get() = [email protected]
+ get() = [email protected]
override val extendedTouchPadding: Size
- get() = [email protected]
+ get() = [email protected]
fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
if (pass == awaitPass) {
@@ -612,6 +662,7 @@
PointerEventTimeoutCancellationException(timeMillis)
)
}
+
val job = coroutineScope.launch {
// Delay twice because the timeout continuation needs to be lower-priority than
// input events, not treated fairly in FIFO order. The second
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index a7a67f3..f07ffc6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -47,6 +47,7 @@
import androidx.compose.ui.node.Nodes.FocusEvent
import androidx.compose.ui.node.Nodes.FocusProperties
import androidx.compose.ui.node.Nodes.FocusTarget
+import androidx.compose.ui.node.Nodes.SuspendPointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalViewConfiguration
@@ -661,6 +662,8 @@
if (field != value) {
field = value
onDensityOrLayoutDirectionChanged()
+
+ invalidatePointerInputModifiers()
}
}
@@ -676,6 +679,13 @@
}
override var viewConfiguration: ViewConfiguration = DummyViewConfiguration
+ set(value) {
+ if (field != value) {
+ field = value
+ invalidatePointerInputModifiers()
+ }
+ }
+
override var compositionLocalMap = CompositionLocalMap.Empty
set(value) {
field = value
@@ -703,6 +713,14 @@
invalidateLayers()
}
+ // The pointer input's code block (for handling incoming events) needs to be reset if either
+ // the density or view configuration changes (see implementation for more details).
+ private fun invalidatePointerInputModifiers() {
+ nodes.headToTail(type = SuspendPointerInput) {
+ it.resetBlock()
+ }
+ }
+
/**
* The measured width of this layout and all of its [modifier]s. Shortcut for `size.width`.
*/
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
index 04a697e..b451d40 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
@@ -29,6 +29,7 @@
import androidx.compose.ui.focus.FocusTargetModifierNode
import androidx.compose.ui.input.key.KeyInputModifierNode
import androidx.compose.ui.input.pointer.PointerInputModifier
+import androidx.compose.ui.input.pointer.SuspendPointerInputModifierNode
import androidx.compose.ui.input.rotary.RotaryInputModifierNode
import androidx.compose.ui.layout.IntermediateLayoutModifierNode
import androidx.compose.ui.layout.LayoutModifier
@@ -39,6 +40,7 @@
import androidx.compose.ui.modifier.ModifierLocalConsumer
import androidx.compose.ui.modifier.ModifierLocalNode
import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.node.Nodes.SuspendPointerInput
import androidx.compose.ui.semantics.SemanticsModifier
@JvmInline
@@ -95,6 +97,8 @@
@JvmStatic
inline val CompositionLocalConsumer
get() = NodeKind(0b1 shl 15)
+ @JvmStatic
+ inline val SuspendPointerInput get() = NodeKind(0b1 shl 16)
// ...
}
@@ -188,6 +192,10 @@
if (node is CompositionLocalConsumerModifierNode) {
mask = mask or Nodes.CompositionLocalConsumer
}
+ if (node is SuspendPointerInputModifierNode) {
+ mask = mask or SuspendPointerInput
+ }
+
return mask
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
index deb97c5..c6a86b8 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
@@ -19,6 +19,7 @@
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputFilter
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.unit.IntSize
@@ -28,7 +29,7 @@
* [PointerInputModifierNode]s don't also react to them.
*
* This is the [androidx.compose.ui.Modifier.Node] equivalent of
- * [androidx.compose.ui.input.pointer.PointerInputModifier]
+ * [androidx.compose.ui.input.pointer.PointerInputFilter].
*
* @sample androidx.compose.ui.samples.PointerInputModifierNodeSample
*/
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
index 392192f..9f25017 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
@@ -19,7 +19,6 @@
import android.os.Build
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -44,8 +43,6 @@
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.Key
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -346,41 +343,6 @@
}
@Test
- fun clickableSurface_allowsFinalPassChildren() {
- val hitTested = mutableStateOf(false)
-
- rule.setContent {
- Box(Modifier.fillMaxSize()) {
- Surface(
- modifier = Modifier
- .fillMaxSize()
- .testTag("surface"),
- onClick = {}
- ) {
- Box(
- Modifier
- .fillMaxSize()
- .testTag("pressable")
- .pointerInput(Unit) {
- awaitEachGesture {
- hitTested.value = true
- val event = awaitPointerEvent(PointerEventPass.Final)
- Truth
- .assertThat(event.changes[0].isConsumed)
- .isFalse()
- }
- }
- )
- }
- }
- }
- rule.onNodeWithTag("surface").performSemanticsAction(SemanticsActions.RequestFocus)
- rule.onNodeWithTag("pressable", true)
- .performKeyInput { pressKey(Key.DirectionCenter) }
- Truth.assertThat(hitTested.value).isTrue()
- }
-
- @Test
fun clickableSurface_reactsToStateChange() {
val interactionSource = MutableInteractionSource()
var isPressed by mutableStateOf(false)
@@ -658,42 +620,6 @@
}
@Test
- fun toggleableSurface_allowsFinalPassChildren() {
- val hitTested = mutableStateOf(false)
-
- rule.setContent {
- Box(Modifier.fillMaxSize()) {
- Surface(
- checked = false,
- modifier = Modifier
- .fillMaxSize()
- .testTag("surface"),
- onCheckedChange = {}
- ) {
- Box(
- Modifier
- .fillMaxSize()
- .testTag("pressable")
- .pointerInput(Unit) {
- awaitEachGesture {
- hitTested.value = true
- val event = awaitPointerEvent(PointerEventPass.Final)
- Truth
- .assertThat(event.changes[0].isConsumed)
- .isFalse()
- }
- }
- )
- }
- }
- }
- rule.onNodeWithTag("surface").performSemanticsAction(SemanticsActions.RequestFocus)
- rule.onNodeWithTag("pressable", true)
- .performKeyInput { pressKey(Key.DirectionCenter) }
- Truth.assertThat(hitTested.value).isTrue()
- }
-
- @Test
fun toggleableSurface_reactsToStateChange() {
val interactionSource = MutableInteractionSource()
var isPressed by mutableStateOf(false)