1 /*
<lambda>null2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.input.pointer
18
19 import android.content.Context
20 import android.os.Build
21 import android.view.InputDevice
22 import android.view.MotionEvent
23 import android.view.MotionEvent.ACTION_BUTTON_PRESS
24 import android.view.MotionEvent.ACTION_BUTTON_RELEASE
25 import android.view.MotionEvent.ACTION_CANCEL
26 import android.view.MotionEvent.ACTION_DOWN
27 import android.view.MotionEvent.ACTION_HOVER_ENTER
28 import android.view.MotionEvent.ACTION_HOVER_EXIT
29 import android.view.MotionEvent.ACTION_HOVER_MOVE
30 import android.view.MotionEvent.ACTION_MOVE
31 import android.view.MotionEvent.ACTION_OUTSIDE
32 import android.view.MotionEvent.ACTION_POINTER_DOWN
33 import android.view.MotionEvent.ACTION_POINTER_INDEX_SHIFT
34 import android.view.MotionEvent.ACTION_POINTER_UP
35 import android.view.MotionEvent.ACTION_SCROLL
36 import android.view.MotionEvent.ACTION_UP
37 import android.view.MotionEvent.PointerCoords
38 import android.view.MotionEvent.TOOL_TYPE_FINGER
39 import android.view.MotionEvent.TOOL_TYPE_MOUSE
40 import android.view.View
41 import android.view.ViewGroup
42 import androidx.activity.ComponentActivity
43 import androidx.annotation.RequiresApi
44 import androidx.compose.foundation.gestures.awaitFirstDown
45 import androidx.compose.foundation.gestures.detectTapGestures
46 import androidx.compose.foundation.layout.Arrangement
47 import androidx.compose.foundation.layout.Box
48 import androidx.compose.foundation.layout.Column
49 import androidx.compose.foundation.layout.fillMaxSize
50 import androidx.compose.foundation.layout.height
51 import androidx.compose.foundation.layout.offset
52 import androidx.compose.foundation.layout.padding
53 import androidx.compose.foundation.layout.requiredSize
54 import androidx.compose.foundation.layout.size
55 import androidx.compose.foundation.layout.width
56 import androidx.compose.foundation.layout.wrapContentSize
57 import androidx.compose.runtime.Composable
58 import androidx.compose.runtime.getValue
59 import androidx.compose.runtime.mutableStateOf
60 import androidx.compose.runtime.remember
61 import androidx.compose.runtime.rememberCoroutineScope
62 import androidx.compose.runtime.setValue
63 import androidx.compose.runtime.snapshots.Snapshot
64 import androidx.compose.ui.AbsoluteAlignment
65 import androidx.compose.ui.Alignment
66 import androidx.compose.ui.Modifier
67 import androidx.compose.ui.OpenComposeView
68 import androidx.compose.ui.background
69 import androidx.compose.ui.composed
70 import androidx.compose.ui.draw.clipToBounds
71 import androidx.compose.ui.draw.scale
72 import androidx.compose.ui.findAndroidComposeView
73 import androidx.compose.ui.geometry.Offset
74 import androidx.compose.ui.gesture.PointerCoords
75 import androidx.compose.ui.gesture.PointerProperties
76 import androidx.compose.ui.graphics.Color
77 import androidx.compose.ui.graphics.graphicsLayer
78 import androidx.compose.ui.layout.Layout
79 import androidx.compose.ui.layout.LayoutCoordinates
80 import androidx.compose.ui.layout.findRootCoordinates
81 import androidx.compose.ui.layout.layout
82 import androidx.compose.ui.layout.onGloballyPositioned
83 import androidx.compose.ui.layout.onPlaced
84 import androidx.compose.ui.platform.AndroidComposeView
85 import androidx.compose.ui.platform.ComposeView
86 import androidx.compose.ui.platform.LocalDensity
87 import androidx.compose.ui.platform.LocalViewConfiguration
88 import androidx.compose.ui.unit.Dp
89 import androidx.compose.ui.unit.IntSize
90 import androidx.compose.ui.unit.dp
91 import androidx.compose.ui.util.fastAll
92 import androidx.compose.ui.util.fastForEach
93 import androidx.compose.ui.viewinterop.AndroidView
94 import androidx.compose.ui.zIndex
95 import androidx.test.ext.junit.runners.AndroidJUnit4
96 import androidx.test.filters.SdkSuppress
97 import androidx.test.filters.SmallTest
98 import androidx.testutils.waitForFutureFrame
99 import com.google.common.truth.Truth.assertThat
100 import java.util.concurrent.CountDownLatch
101 import java.util.concurrent.TimeUnit
102 import kotlin.math.ceil
103 import kotlinx.coroutines.CoroutineScope
104 import kotlinx.coroutines.delay
105 import kotlinx.coroutines.launch
106 import org.junit.Assert.assertEquals
107 import org.junit.Assert.assertFalse
108 import org.junit.Assert.assertTrue
109 import org.junit.Before
110 import org.junit.Rule
111 import org.junit.Test
112 import org.junit.runner.RunWith
113 import org.mockito.kotlin.any
114 import org.mockito.kotlin.never
115 import org.mockito.kotlin.spy
116 import org.mockito.kotlin.verify
117
118 @SmallTest
119 @RunWith(AndroidJUnit4::class)
120 class AndroidPointerInputTest {
121 @Suppress("DEPRECATION")
122 @get:Rule
123 val rule = androidx.test.rule.ActivityTestRule(AndroidPointerInputTestActivity::class.java)
124
125 private lateinit var container: OpenComposeView
126
127 private fun roundUpDpToNearestHundred(dpValue: Dp): Dp {
128 val roundedValue = ceil(dpValue.value / 100f).toInt() * 100
129 return roundedValue.dp
130 }
131
132 @Before
133 fun setup() {
134 val activity = rule.activity
135 container = spy(OpenComposeView(activity))
136
137 rule.runOnUiThread {
138 activity.setContentView(
139 container,
140 ViewGroup.LayoutParams(
141 ViewGroup.LayoutParams.WRAP_CONTENT,
142 ViewGroup.LayoutParams.WRAP_CONTENT
143 )
144 )
145 }
146 }
147
148 @Test
149 fun dispatchTouchEvent_invalidCoordinates() {
150 countDown { latch ->
151 rule.runOnUiThread {
152 container.setContent {
153 FillLayout(
154 Modifier.consumeMovementGestureFilter().onGloballyPositioned {
155 latch.countDown()
156 }
157 )
158 }
159 }
160 }
161
162 rule.runOnUiThread {
163 val motionEvent =
164 MotionEvent(
165 0,
166 ACTION_DOWN,
167 1,
168 0,
169 arrayOf(PointerProperties(0)),
170 arrayOf(PointerCoords(Float.NaN, Float.NaN))
171 )
172
173 val androidComposeView = findAndroidComposeView(container)!!
174 // Act
175 val actual = androidComposeView.dispatchTouchEvent(motionEvent)
176
177 // Assert
178 assertThat(actual).isFalse()
179 }
180 }
181
182 @Test
183 fun dispatchTouchEvent_infiniteCoordinates() {
184 countDown { latch ->
185 rule.runOnUiThread {
186 container.setContent {
187 FillLayout(
188 Modifier.consumeMovementGestureFilter().onGloballyPositioned {
189 latch.countDown()
190 }
191 )
192 }
193 }
194 }
195
196 rule.runOnUiThread {
197 val motionEvent =
198 MotionEvent(
199 0,
200 ACTION_DOWN,
201 1,
202 0,
203 arrayOf(PointerProperties(0)),
204 arrayOf(PointerCoords(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY))
205 )
206
207 val androidComposeView = findAndroidComposeView(container)!!
208 // Act
209 val actual = androidComposeView.dispatchTouchEvent(motionEvent)
210
211 // Assert
212 assertThat(actual).isFalse()
213 }
214 }
215
216 @Test
217 @SdkSuppress(maxSdkVersion = 34) // b/384972397: Failing on SDK 35
218 fun dispatchTouchEvent_noPointerInputModifiers_returnsFalse() {
219
220 // Arrange
221
222 countDown { latch ->
223 rule.runOnUiThread {
224 container.setContent {
225 FillLayout(Modifier.onGloballyPositioned { latch.countDown() })
226 }
227 }
228 }
229
230 rule.runOnUiThread {
231 val motionEvent =
232 MotionEvent(
233 0,
234 ACTION_DOWN,
235 1,
236 0,
237 arrayOf(PointerProperties(0)),
238 arrayOf(PointerCoords(0f, 0f))
239 )
240
241 // Act
242 val actual = findRootView(container).dispatchTouchEvent(motionEvent)
243
244 // Assert
245 assertThat(actual).isFalse()
246 }
247 }
248
249 /**
250 * Recreates dispatch of non-system created cancellation [MotionEvent] (that is, developer
251 * created) while system is already handling multiple [MotionEvent]s. Due to the platform not
252 * allowing reentrancy while handling [MotionEvent]s, the cancellation event will be ignored.
253 */
254 @Test
255 fun dispatchTouchEvents_eventCancelledDuringProcessing_doesNotCancel() {
256 // Arrange
257 var topBoxInnerCoordinates: LayoutCoordinates? = null
258 var bottomBoxInnerCoordinates: LayoutCoordinates? = null
259
260 val latch = CountDownLatch(2)
261
262 val pointerEventsLog = mutableListOf<PointerEvent>()
263
264 rule.runOnUiThread {
265 container.setContent {
266 Box(modifier = Modifier.fillMaxSize()) {
267 // Top Box
268 Box(
269 modifier =
270 Modifier.size(50.dp)
271 .align(AbsoluteAlignment.TopLeft)
272 .pointerInput(Unit) {
273 awaitPointerEventScope {
274 while (true) {
275 val event = awaitPointerEvent()
276 event.changes.forEach { it.consume() }
277 pointerEventsLog += event
278
279 // Actual dispatch of non-system created cancellation
280 // [MotionEvent] while other [MotionEvent]s are being
281 // handled.
282 if (event.type == PointerEventType.Move) {
283 dispatchTouchEvent(
284 ACTION_CANCEL,
285 topBoxInnerCoordinates!!
286 )
287 }
288 }
289 }
290 }
291 .onGloballyPositioned {
292 topBoxInnerCoordinates = it
293 latch.countDown()
294 }
295 )
296
297 // Bottom Box
298 Box(
299 modifier =
300 Modifier.size(60.dp)
301 .align(AbsoluteAlignment.BottomRight)
302 .pointerInput(Unit) {
303 awaitPointerEventScope {
304 while (true) {
305 val event = awaitPointerEvent()
306 event.changes.forEach { it.consume() }
307 pointerEventsLog += event
308 }
309 }
310 }
311 .onGloballyPositioned {
312 bottomBoxInnerCoordinates = it
313 latch.countDown()
314 }
315 )
316 }
317 }
318 }
319
320 assertTrue(latch.await(1, TimeUnit.SECONDS))
321
322 rule.runOnUiThread {
323 // Arrange continued
324 val root = topBoxInnerCoordinates!!.findRootCoordinates()
325 val topBoxOffset = root.localPositionOf(topBoxInnerCoordinates!!, Offset.Zero)
326 val bottomBoxOffset = root.localPositionOf(bottomBoxInnerCoordinates!!, Offset.Zero)
327
328 val topBoxFingerPointerPropertiesId = 0
329 val bottomBoxFingerPointerPropertiesId = 1
330
331 val topBoxPointerProperties =
332 PointerProperties(topBoxFingerPointerPropertiesId).also {
333 it.toolType = MotionEvent.TOOL_TYPE_FINGER
334 }
335 val bottomBoxPointerProperties =
336 PointerProperties(bottomBoxFingerPointerPropertiesId).also {
337 it.toolType = MotionEvent.TOOL_TYPE_FINGER
338 }
339
340 var eventStartTime = 0
341
342 val downTopBoxEvent =
343 MotionEvent(
344 eventStartTime,
345 action = ACTION_DOWN,
346 numPointers = 1,
347 actionIndex = 0,
348 pointerProperties = arrayOf(topBoxPointerProperties),
349 pointerCoords = arrayOf(PointerCoords(topBoxOffset.x, topBoxOffset.y))
350 )
351
352 eventStartTime += 500
353 val downBottomBoxEvent =
354 MotionEvent(
355 eventStartTime,
356 action = ACTION_POINTER_DOWN,
357 numPointers = 2,
358 actionIndex = 1,
359 pointerProperties =
360 arrayOf(topBoxPointerProperties, bottomBoxPointerProperties),
361 pointerCoords =
362 arrayOf(
363 PointerCoords(topBoxOffset.x, topBoxOffset.y),
364 PointerCoords(bottomBoxOffset.x, bottomBoxOffset.y)
365 )
366 )
367
368 eventStartTime += 500
369 val moveTopBoxEvent =
370 MotionEvent(
371 eventStartTime,
372 action = ACTION_MOVE,
373 numPointers = 2,
374 actionIndex = 0,
375 pointerProperties =
376 arrayOf(topBoxPointerProperties, bottomBoxPointerProperties),
377 pointerCoords =
378 arrayOf(
379 PointerCoords(topBoxOffset.x + 10, topBoxOffset.y),
380 PointerCoords(bottomBoxOffset.x + 10, bottomBoxOffset.y)
381 )
382 )
383
384 eventStartTime += 500
385 val moveBottomBoxEvent =
386 MotionEvent(
387 eventStartTime,
388 action = ACTION_MOVE,
389 numPointers = 2,
390 actionIndex = 1,
391 pointerProperties =
392 arrayOf(topBoxPointerProperties, bottomBoxPointerProperties),
393 pointerCoords =
394 arrayOf(
395 PointerCoords(topBoxOffset.x + 10, topBoxOffset.y),
396 PointerCoords(bottomBoxOffset.x + 10, bottomBoxOffset.y)
397 )
398 )
399
400 eventStartTime += 500
401 val upTopBoxEvent =
402 MotionEvent(
403 eventStartTime,
404 action = ACTION_POINTER_UP,
405 numPointers = 2,
406 actionIndex = 0,
407 pointerProperties =
408 arrayOf(topBoxPointerProperties, bottomBoxPointerProperties),
409 pointerCoords =
410 arrayOf(
411 PointerCoords(topBoxOffset.x + 10, topBoxOffset.y),
412 PointerCoords(bottomBoxOffset.x + 10, bottomBoxOffset.y)
413 )
414 )
415
416 eventStartTime += 500
417 val upBottomBoxEvent =
418 MotionEvent(
419 eventStartTime,
420 action = ACTION_UP,
421 numPointers = 1,
422 actionIndex = 0,
423 pointerProperties = arrayOf(bottomBoxPointerProperties),
424 pointerCoords =
425 arrayOf(PointerCoords(bottomBoxOffset.x + 10, bottomBoxOffset.y))
426 )
427
428 // Act
429 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
430
431 androidComposeView.dispatchTouchEvent(downTopBoxEvent)
432 androidComposeView.dispatchTouchEvent(downBottomBoxEvent)
433 androidComposeView.dispatchTouchEvent(moveTopBoxEvent)
434 androidComposeView.dispatchTouchEvent(moveBottomBoxEvent)
435 androidComposeView.dispatchTouchEvent(upTopBoxEvent)
436 androidComposeView.dispatchTouchEvent(upBottomBoxEvent)
437
438 // Assert
439 assertThat(pointerEventsLog).hasSize(8)
440
441 for (pointerEvent in pointerEventsLog) {
442 assertThat(pointerEvent.internalPointerEvent).isNotNull()
443 }
444
445 assertThat(pointerEventsLog[0].type).isEqualTo(PointerEventType.Press)
446 assertThat(pointerEventsLog[1].type).isEqualTo(PointerEventType.Press)
447 assertThat(pointerEventsLog[2].type).isEqualTo(PointerEventType.Press)
448
449 assertThat(pointerEventsLog[3].type).isEqualTo(PointerEventType.Move)
450 assertThat(pointerEventsLog[4].type).isEqualTo(PointerEventType.Move)
451
452 assertThat(pointerEventsLog[5].type).isEqualTo(PointerEventType.Release)
453 assertThat(pointerEventsLog[6].type).isEqualTo(PointerEventType.Release)
454 assertThat(pointerEventsLog[7].type).isEqualTo(PointerEventType.Release)
455 }
456 }
457
458 @Test
459 fun dispatchTouchEvent_pointerInputModifier_returnsTrue() {
460
461 // Arrange
462
463 countDown { latch ->
464 rule.runOnUiThread {
465 container.setContent {
466 FillLayout(
467 Modifier.consumeMovementGestureFilter().onGloballyPositioned {
468 latch.countDown()
469 }
470 )
471 }
472 }
473 }
474
475 rule.runOnUiThread {
476 val locationInWindow = IntArray(2).also { container.getLocationInWindow(it) }
477
478 val motionEvent =
479 MotionEvent(
480 0,
481 ACTION_DOWN,
482 1,
483 0,
484 arrayOf(PointerProperties(0)),
485 arrayOf(
486 PointerCoords(locationInWindow[0].toFloat(), locationInWindow[1].toFloat())
487 )
488 )
489
490 // Act
491 val actual = findRootView(container).dispatchTouchEvent(motionEvent)
492
493 // Assert
494 assertThat(actual).isTrue()
495 }
496 }
497
498 @Test
499 fun dispatchTouchEvent_movementNotConsumed_requestDisallowInterceptTouchEventNotCalled() {
500 dispatchTouchEvent_movementConsumptionInCompose(
501 consumeMovement = false,
502 callsRequestDisallowInterceptTouchEvent = false
503 )
504 }
505
506 @Test
507 fun dispatchTouchEvent_movementConsumed_requestDisallowInterceptTouchEventCalled() {
508 dispatchTouchEvent_movementConsumptionInCompose(
509 consumeMovement = true,
510 callsRequestDisallowInterceptTouchEvent = true
511 )
512 }
513
514 @Test
515 fun dispatchTouchEvent_notMeasuredLayoutsAreMeasuredFirst() {
516 val size = mutableStateOf(10)
517 val latch = CountDownLatch(1)
518 var consumedDownPosition: Offset? = null
519 rule.runOnUiThread {
520 container.setContent {
521 Box(Modifier.fillMaxSize().wrapContentSize(align = AbsoluteAlignment.TopLeft)) {
522 Layout(
523 {},
524 Modifier.consumeDownGestureFilter { consumedDownPosition = it }
525 .onGloballyPositioned { latch.countDown() }
526 ) { _, _ ->
527 val sizePx = size.value
528 layout(sizePx, sizePx) {}
529 }
530 }
531 }
532 }
533
534 assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue()
535
536 rule.runOnUiThread {
537 // we update size from 10 to 20 pixels
538 size.value = 20
539 // this call will synchronously mark the LayoutNode as needs remeasure
540 Snapshot.sendApplyNotifications()
541 val locationInWindow = IntArray(2).also { container.getLocationInWindow(it) }
542
543 val motionEvent =
544 MotionEvent(
545 0,
546 ACTION_DOWN,
547 1,
548 0,
549 arrayOf(PointerProperties(0)),
550 arrayOf(PointerCoords(locationInWindow[0] + 15f, locationInWindow[1] + 15f))
551 )
552
553 // we expect it to first remeasure and only then process
554 findRootView(container).dispatchTouchEvent(motionEvent)
555
556 assertThat(consumedDownPosition).isEqualTo(Offset(15f, 15f))
557 }
558 }
559
560 @Test
561 fun dispatchTouchEvent_throughLayersOfAndroidAndCompose_hitsChildWithCorrectCoords() {
562
563 // Arrange
564
565 val context = rule.activity
566
567 val log = mutableListOf<List<PointerInputChange>>()
568
569 countDown { latch ->
570 rule.runOnUiThread {
571 container.setContent {
572 AndroidWithCompose(context, 1) {
573 AndroidWithCompose(context, 10) {
574 AndroidWithCompose(context, 100) {
575 Layout(
576 {},
577 Modifier.logEventsGestureFilter(log).onGloballyPositioned {
578 latch.countDown()
579 }
580 ) { _, _ ->
581 layout(5, 5) {}
582 }
583 }
584 }
585 }
586 }
587 }
588 }
589
590 rule.runOnUiThread {
591 val locationInWindow = IntArray(2).also { container.getLocationInWindow(it) }
592
593 val motionEvent =
594 MotionEvent(
595 0,
596 ACTION_DOWN,
597 1,
598 0,
599 arrayOf(PointerProperties(0)),
600 arrayOf(
601 PointerCoords(
602 locationInWindow[0].toFloat() + 1 + 10 + 100,
603 locationInWindow[1].toFloat() + 1 + 10 + 100
604 )
605 )
606 )
607
608 // Act
609 findRootView(container).dispatchTouchEvent(motionEvent)
610
611 // Assert
612 assertThat(log).hasSize(1)
613 assertThat(log[0]).hasSize(1)
614 assertThat(log[0][0].position).isEqualTo(Offset(0f, 0f))
615 }
616 }
617
618 private fun dispatchTouchEvent_movementConsumptionInCompose(
619 consumeMovement: Boolean,
620 callsRequestDisallowInterceptTouchEvent: Boolean
621 ) {
622
623 // Arrange
624
625 countDown { latch ->
626 rule.runOnUiThread {
627 container.setContent {
628 FillLayout(
629 Modifier.consumeMovementGestureFilter(consumeMovement)
630 .onGloballyPositioned { latch.countDown() }
631 )
632 }
633 }
634 }
635
636 rule.runOnUiThread {
637 val (x, y) =
638 IntArray(2).let { array ->
639 container.getLocationInWindow(array)
640 array.map { item -> item.toFloat() }
641 }
642
643 val down =
644 MotionEvent(
645 0,
646 ACTION_DOWN,
647 1,
648 0,
649 arrayOf(PointerProperties(0)),
650 arrayOf(PointerCoords(x, y))
651 )
652
653 val move =
654 MotionEvent(
655 0,
656 ACTION_MOVE,
657 1,
658 0,
659 arrayOf(PointerProperties(0)),
660 arrayOf(PointerCoords(x + 1, y))
661 )
662
663 findRootView(container).dispatchTouchEvent(down)
664
665 // Act
666 findRootView(container).dispatchTouchEvent(move)
667
668 // Assert
669 if (callsRequestDisallowInterceptTouchEvent) {
670 verify(container).requestDisallowInterceptTouchEvent(true)
671 } else {
672 verify(container, never()).requestDisallowInterceptTouchEvent(any())
673 }
674 }
675 }
676
677 /**
678 * This test verifies that if the AndroidComposeView is offset directly by a call to
679 * "offsetTopAndBottom(int)", that pointer locations are correct when dispatched down to a child
680 * PointerInputModifier.
681 */
682 @Test
683 fun dispatchTouchEvent_androidComposeViewOffset_positionIsCorrect() {
684
685 // Arrange
686
687 val offset = 50
688 val log = mutableListOf<List<PointerInputChange>>()
689
690 countDown { latch ->
691 rule.runOnUiThread {
692 container.setContent {
693 FillLayout(
694 Modifier.logEventsGestureFilter(log).onGloballyPositioned {
695 latch.countDown()
696 }
697 )
698 }
699 }
700 }
701
702 rule.runOnUiThread {
703 // Get the current location in window.
704 val locationInWindow = IntArray(2).also { container.getLocationInWindow(it) }
705
706 // Offset the androidComposeView.
707 container.offsetTopAndBottom(offset)
708
709 // Create a motion event that is also offset.
710 val motionEvent =
711 MotionEvent(
712 0,
713 ACTION_DOWN,
714 1,
715 0,
716 arrayOf(PointerProperties(0)),
717 arrayOf(
718 PointerCoords(
719 locationInWindow[0].toFloat(),
720 locationInWindow[1].toFloat() + offset
721 )
722 )
723 )
724
725 // Act
726 findRootView(container).dispatchTouchEvent(motionEvent)
727
728 // Assert
729 assertThat(log).hasSize(1)
730 assertThat(log[0]).hasSize(1)
731 assertThat(log[0][0].position).isEqualTo(Offset(0f, 0f))
732 }
733 }
734
735 /*
736 * Tests that a long press is NOT triggered when an up event (following a down event) isn't
737 * executed right away because the UI thread is delayed past the long press timeout.
738 *
739 * Note: This test is a bit complicated because it needs to properly execute events in order
740 * using multiple coroutine delay()s and Thread.sleep() in the main thread.
741 *
742 * Expected behavior: When the UI thread wakes up, the up event should be triggered before the
743 * second delay() in the withTimeout() (which foundation's long press uses). Thus, the tap
744 * triggers and NOT the long press.
745 *
746 * Actual steps this test uses to recreate this scenario:
747 * 1. Down event is triggered
748 * 2. An up event is scheduled to be triggered BEFORE the timeout for a long press (uses
749 * a coroutine sleep() that is less than the long press timeout).
750 * 3. The UI thread sleeps before the sleep() awakens to fire the up event (the sleep time is
751 * LONGER than the long press timeout).
752 * 4. The UI thread wakes up, executes the first sleep() for the long press timeout
753 * (in withTimeout() implementation [`SuspendingPointerInputModifierNodeImpl`])
754 * 5. The up event is fired (sleep() for test coroutine finishes).
755 * 6. Tap is triggered (that is, long press is NOT triggered because the second sleep() is
756 * NOT executed in withTimeout()).
757 */
758 @Test
759 fun detectTapGestures_blockedMainThread() {
760 var didLongPress = false
761 var didTap = false
762
763 val positionedLatch = CountDownLatch(1)
764 var pressLatch = CountDownLatch(1)
765 var clickOrLongPressLatch = CountDownLatch(1)
766
767 lateinit var coroutineScope: CoroutineScope
768
769 val locationInWindow = IntArray(2)
770
771 // Less than long press timeout
772 val touchUpDelay = 100
773 // 400L
774 val longPressTimeout = android.view.ViewConfiguration.getLongPressTimeout()
775 // Goes past long press timeout (above)
776 val sleepTime = longPressTimeout + 100L
777 // matches first delay time in [PointerEventHandlerCoroutine.withTimeout()]
778 val withTimeoutDelay = longPressTimeout - WITH_TIMEOUT_MICRO_DELAY_MILLIS
779 var upEvent: MotionEvent? = null
780
781 rule.runOnUiThread {
782 container.setContent {
783 coroutineScope = rememberCoroutineScope()
784
785 FillLayout(
786 Modifier.pointerInput(Unit) {
787 detectTapGestures(
788 onLongPress = {
789 didLongPress = true
790 clickOrLongPressLatch.countDown()
791 },
792 onTap = {
793 didTap = true
794 clickOrLongPressLatch.countDown()
795 },
796 onPress = {
797 // AwaitPointerEventScope.waitForLongPress() uses
798 // PointerEventHandlerCoroutine.withTimeout() as part of
799 // the timeout logic to see if a long press has occurred.
800 //
801 // Within PointerEventHandlerCoroutine.withTimeout(), there
802 // is a coroutine with two delay() calls and we are
803 // specifically testing that an up event that is put to
804 // sleep (but within the timeout time), does not trigger a
805 // long press when it comes in between those delay() calls.
806 //
807 // To do that, we want to get the timing of this coroutine
808 // as close to timeout as possible. That is, executing the
809 // up event (after the delay below) right between those
810 // delays to avoid the test being flaky.
811 coroutineScope.launch {
812 // Matches first delay used with withTimeout() for long
813 // press.
814 delay(withTimeoutDelay)
815 findRootView(container).dispatchTouchEvent(upEvent!!)
816 }
817 pressLatch.countDown()
818 }
819 )
820 }
821 .onGloballyPositioned { positionedLatch.countDown() }
822 )
823 }
824 }
825
826 assertTrue(positionedLatch.await(1, TimeUnit.SECONDS))
827 container.getLocationInWindow(locationInWindow)
828
829 repeat(5) { iteration ->
830 rule.runOnUiThread {
831 val downEvent =
832 createPointerEventAt(
833 iteration * sleepTime.toInt(),
834 ACTION_DOWN,
835 locationInWindow
836 )
837
838 upEvent =
839 createPointerEventAt(
840 touchUpDelay + iteration * sleepTime.toInt(),
841 ACTION_UP,
842 locationInWindow
843 )
844 findRootView(container).dispatchTouchEvent(downEvent)
845 }
846
847 assertTrue(pressLatch.await(1, TimeUnit.SECONDS))
848
849 // Blocks the UI thread from now until past the long-press
850 // timeout. This tests that even in pathological situations,
851 // the upEvent is still processed before the long-press
852 // timeout.
853 rule.runOnUiThread { Thread.sleep(sleepTime) }
854
855 assertTrue(clickOrLongPressLatch.await(1, TimeUnit.SECONDS))
856
857 assertFalse(didLongPress)
858 assertTrue(didTap)
859
860 didTap = false
861 clickOrLongPressLatch = CountDownLatch(1)
862 pressLatch = CountDownLatch(1)
863 }
864 }
865
866 /**
867 * When a modifier is added, it should work, even when it takes the position of a previous
868 * modifier.
869 */
870 @Test
871 fun recomposeWithNewModifier() {
872 var tap2Enabled by mutableStateOf(false)
873 var tapLatch = CountDownLatch(1)
874 val tapLatch2 = CountDownLatch(1)
875 var positionedLatch = CountDownLatch(1)
876
877 rule.runOnUiThread {
878 container.setContent {
879 FillLayout(
880 Modifier.pointerInput(Unit) { detectTapGestures { tapLatch.countDown() } }
881 .then(
882 if (tap2Enabled)
883 Modifier.pointerInput(Unit) {
884 detectTapGestures { tapLatch2.countDown() }
885 }
886 else Modifier
887 )
888 .onGloballyPositioned { positionedLatch.countDown() }
889 )
890 }
891 }
892
893 assertTrue(positionedLatch.await(1, TimeUnit.SECONDS))
894
895 val locationInWindow = IntArray(2)
896 rule.runOnUiThread {
897 // Get the current location in window.
898 container.getLocationInWindow(locationInWindow)
899
900 val downEvent = createPointerEventAt(0, ACTION_DOWN, locationInWindow)
901 findRootView(container).dispatchTouchEvent(downEvent)
902 }
903
904 rule.runOnUiThread {
905 val upEvent = createPointerEventAt(200, ACTION_UP, locationInWindow)
906 findRootView(container).dispatchTouchEvent(upEvent)
907 }
908
909 assertTrue(tapLatch.await(1, TimeUnit.SECONDS))
910 tapLatch = CountDownLatch(1)
911
912 positionedLatch = CountDownLatch(1)
913 tap2Enabled = true
914 assertTrue(positionedLatch.await(1, TimeUnit.SECONDS))
915
916 rule.runOnUiThread {
917 val downEvent = createPointerEventAt(1000, ACTION_DOWN, locationInWindow)
918 findRootView(container).dispatchTouchEvent(downEvent)
919 }
920 // Need to wait for long press timeout (at least)
921 rule.runOnUiThread {
922 val upEvent = createPointerEventAt(1030, ACTION_UP, locationInWindow)
923 findRootView(container).dispatchTouchEvent(upEvent)
924 }
925 assertTrue(tapLatch2.await(1, TimeUnit.SECONDS))
926
927 positionedLatch = CountDownLatch(1)
928 tap2Enabled = false
929 assertTrue(positionedLatch.await(1, TimeUnit.SECONDS))
930
931 rule.runOnUiThread {
932 val downEvent = createPointerEventAt(2000, ACTION_DOWN, locationInWindow)
933 findRootView(container).dispatchTouchEvent(downEvent)
934 }
935 rule.runOnUiThread {
936 val upEvent = createPointerEventAt(2200, ACTION_UP, locationInWindow)
937 findRootView(container).dispatchTouchEvent(upEvent)
938 }
939 assertTrue(tapLatch.await(1, TimeUnit.SECONDS))
940 }
941
942 /**
943 * There are times that getLocationOnScreen() returns (0, 0). Touch input should still arrive at
944 * the correct place even if getLocationOnScreen() gives a different result than the rawX, rawY
945 * indicate.
946 */
947 @Test
948 fun badGetLocationOnScreen() {
949 val tapLatch = CountDownLatch(1)
950 val layoutLatch = CountDownLatch(1)
951 rule.runOnUiThread {
952 container.setContent {
953 with(LocalDensity.current) {
954 Box(
955 Modifier.size(250.toDp()).layout { measurable, constraints ->
956 val p = measurable.measure(constraints)
957 layout(p.width, p.height) {
958 p.place(0, 0)
959 layoutLatch.countDown()
960 }
961 }
962 ) {
963 Box(
964 Modifier.align(AbsoluteAlignment.TopLeft)
965 .pointerInput(Unit) {
966 awaitPointerEventScope {
967 awaitFirstDown()
968 tapLatch.countDown()
969 }
970 }
971 .size(10.toDp())
972 )
973 }
974 }
975 }
976 }
977 assertTrue(layoutLatch.await(1, TimeUnit.SECONDS))
978 rule.runOnUiThread {}
979
980 val down = createPointerEventAt(0, ACTION_DOWN, intArrayOf(105, 205))
981 down.offsetLocation(-100f, -200f)
982 val composeView = findAndroidComposeView(container) as AndroidComposeView
983 composeView.dispatchTouchEvent(down)
984
985 assertTrue(tapLatch.await(1, TimeUnit.SECONDS))
986 }
987
988 /**
989 * When a scale(0, 0) is used, there is no valid inverse matrix. A touch should not reach an
990 * item that is scaled to 0.
991 */
992 @Test
993 fun badInverseMatrix() {
994 val tapLatch = CountDownLatch(1)
995 val layoutLatch = CountDownLatch(1)
996 var insideTap = 0
997 rule.runOnUiThread {
998 container.setContent {
999 with(LocalDensity.current) {
1000 Box(
1001 Modifier.layout { measurable, constraints ->
1002 val p = measurable.measure(constraints)
1003 layout(p.width, p.height) {
1004 layoutLatch.countDown()
1005 p.place(0, 0)
1006 }
1007 }
1008 .pointerInput(Unit) {
1009 awaitPointerEventScope {
1010 awaitFirstDown()
1011 tapLatch.countDown()
1012 }
1013 }
1014 .requiredSize(10.toDp())
1015 .scale(0f, 0f)
1016 .pointerInput(Unit) {
1017 awaitPointerEventScope {
1018 awaitFirstDown()
1019 insideTap++
1020 }
1021 }
1022 .requiredSize(10.toDp())
1023 )
1024 }
1025 }
1026 }
1027 assertTrue(layoutLatch.await(1, TimeUnit.SECONDS))
1028 rule.runOnUiThread {}
1029
1030 val down = createPointerEventAt(0, ACTION_DOWN, intArrayOf(5, 5))
1031 val composeView = findAndroidComposeView(container) as AndroidComposeView
1032 composeView.dispatchTouchEvent(down)
1033
1034 assertTrue(tapLatch.await(1, TimeUnit.SECONDS))
1035 rule.runOnUiThread { assertEquals(0, insideTap) }
1036 }
1037
1038 @Test
1039 fun dispatchNotAttached() {
1040 val tapLatch = CountDownLatch(1)
1041 val layoutLatch = CountDownLatch(1)
1042 rule.runOnUiThread {
1043 container.setContent {
1044 with(LocalDensity.current) {
1045 Box(
1046 Modifier.onPlaced { layoutLatch.countDown() }
1047 .pointerInput(Unit) {
1048 awaitPointerEventScope {
1049 awaitFirstDown()
1050 tapLatch.countDown()
1051 }
1052 }
1053 .requiredSize(10.toDp())
1054 )
1055 }
1056 }
1057 }
1058 assertTrue(layoutLatch.await(1, TimeUnit.SECONDS))
1059
1060 val composeView = findAndroidComposeView(container) as AndroidComposeView
1061 rule.runOnUiThread {
1062 container.removeAllViews()
1063 val down = createPointerEventAt(0, ACTION_DOWN, intArrayOf(5, 5))
1064 assertFalse(composeView.dispatchTouchEvent(down))
1065 }
1066 }
1067
1068 private fun assertHoverEvent(
1069 event: PointerEvent,
1070 isEnter: Boolean = false,
1071 isExit: Boolean = false
1072 ) {
1073 assertThat(event.changes).hasSize(1)
1074 val change = event.changes[0]
1075 assertThat(change.pressed).isFalse()
1076 assertThat(change.previousPressed).isFalse()
1077 val expectedHoverType =
1078 when {
1079 isEnter -> PointerEventType.Enter
1080 isExit -> PointerEventType.Exit
1081 else -> PointerEventType.Move
1082 }
1083 assertThat(event.type).isEqualTo(expectedHoverType)
1084 }
1085
1086 private fun assertScrollEvent(event: PointerEvent, scrollExpected: Offset) {
1087 assertThat(event.changes).hasSize(1)
1088 val change = event.changes[0]
1089 assertThat(change.pressed).isFalse()
1090 assertThat(event.type).isEqualTo(PointerEventType.Scroll)
1091 // we agreed to reverse Y in android to be in line with other platforms
1092 assertThat(change.scrollDelta).isEqualTo(scrollExpected.copy(y = scrollExpected.y * -1))
1093 }
1094
1095 private fun dispatchMouseEvent(
1096 action: Int,
1097 layoutCoordinates: LayoutCoordinates,
1098 offset: Offset = Offset.Zero,
1099 scrollDelta: Offset = Offset.Zero,
1100 eventTime: Int = 0
1101 ) {
1102 rule.runOnUiThread {
1103 val root = layoutCoordinates.findRootCoordinates()
1104 val pos = root.localPositionOf(layoutCoordinates, offset)
1105 val event =
1106 MotionEvent(
1107 eventTime,
1108 action,
1109 1,
1110 0,
1111 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_MOUSE }),
1112 arrayOf(PointerCoords(pos.x, pos.y, scrollDelta.x, scrollDelta.y))
1113 )
1114
1115 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1116 when (action) {
1117 ACTION_HOVER_ENTER,
1118 ACTION_HOVER_MOVE,
1119 ACTION_HOVER_EXIT -> androidComposeView.dispatchHoverEvent(event)
1120 ACTION_SCROLL -> androidComposeView.dispatchGenericMotionEvent(event)
1121 else -> androidComposeView.dispatchTouchEvent(event)
1122 }
1123 }
1124 }
1125
1126 private fun dispatchStylusEvents(
1127 layoutCoordinates: LayoutCoordinates,
1128 offset: Offset,
1129 vararg actions: Int
1130 ) {
1131 rule.runOnUiThread {
1132 val root = layoutCoordinates.findRootCoordinates()
1133 val pos = root.localPositionOf(layoutCoordinates, offset)
1134 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1135
1136 for (action in actions) {
1137 val event =
1138 MotionEvent(
1139 0,
1140 action,
1141 1,
1142 0,
1143 arrayOf(
1144 PointerProperties(0).also { it.toolType = MotionEvent.TOOL_TYPE_STYLUS }
1145 ),
1146 arrayOf(PointerCoords(pos.x, pos.y))
1147 )
1148
1149 when (action) {
1150 ACTION_HOVER_ENTER,
1151 ACTION_HOVER_MOVE,
1152 ACTION_HOVER_EXIT -> androidComposeView.dispatchHoverEvent(event)
1153 else -> androidComposeView.dispatchTouchEvent(event)
1154 }
1155 }
1156 }
1157 }
1158
1159 private fun dispatchTouchEvent(
1160 action: Int,
1161 layoutCoordinates: LayoutCoordinates,
1162 offset: Offset = Offset.Zero,
1163 eventTime: Int = 0
1164 ) {
1165 rule.runOnUiThread {
1166 val root = layoutCoordinates.findRootCoordinates()
1167 val pos = root.localPositionOf(layoutCoordinates, offset)
1168 val event =
1169 MotionEvent(
1170 eventTime,
1171 action,
1172 1,
1173 0,
1174 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_FINGER }),
1175 arrayOf(PointerCoords(pos.x, pos.y))
1176 )
1177
1178 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1179 androidComposeView.dispatchTouchEvent(event)
1180 }
1181 }
1182
1183 @Test
1184 fun dispatchHoverEnter() {
1185 var layoutCoordinates: LayoutCoordinates? = null
1186 val latch = CountDownLatch(1)
1187 val events = mutableListOf<PointerEvent>()
1188 rule.runOnUiThread {
1189 container.setContent {
1190 Box(
1191 Modifier.fillMaxSize()
1192 .onGloballyPositioned {
1193 layoutCoordinates = it
1194 latch.countDown()
1195 }
1196 .pointerInput(Unit) {
1197 awaitPointerEventScope {
1198 while (true) {
1199 val event = awaitPointerEvent()
1200 event.changes[0].consume()
1201 events += event
1202 }
1203 }
1204 }
1205 )
1206 }
1207 }
1208 assertTrue(latch.await(1, TimeUnit.SECONDS))
1209 dispatchMouseEvent(ACTION_HOVER_ENTER, layoutCoordinates!!)
1210 rule.runOnUiThread {
1211 assertThat(events).hasSize(1)
1212 assertHoverEvent(events[0], isEnter = true)
1213 }
1214 }
1215
1216 @Test
1217 fun dispatchHoverExit() {
1218 var layoutCoordinates: LayoutCoordinates? = null
1219 val latch = CountDownLatch(1)
1220 val events = mutableListOf<PointerEvent>()
1221 rule.runOnUiThread {
1222 container.setContent {
1223 Box(
1224 Modifier.fillMaxSize()
1225 .onGloballyPositioned {
1226 layoutCoordinates = it
1227 latch.countDown()
1228 }
1229 .pointerInput(Unit) {
1230 awaitPointerEventScope {
1231 while (true) {
1232 val event = awaitPointerEvent()
1233 event.changes[0].consume()
1234 events += event
1235 }
1236 }
1237 }
1238 )
1239 }
1240 }
1241 assertTrue(latch.await(1, TimeUnit.SECONDS))
1242 dispatchMouseEvent(ACTION_HOVER_ENTER, layoutCoordinates!!)
1243 dispatchMouseEvent(ACTION_HOVER_EXIT, layoutCoordinates!!, Offset(-1f, -1f))
1244
1245 rule.runOnUiThread {
1246 assertThat(events).hasSize(2)
1247 assertHoverEvent(events[0], isEnter = true)
1248 assertHoverEvent(events[1], isExit = true)
1249 }
1250 }
1251
1252 /*
1253 * Simple test that makes sure a bad ACTION_OUTSIDE MotionEvent doesn't negatively
1254 * impact Compose (b/299074463#comment31). (We actually ignore them in Compose.)
1255 * The event order of MotionEvents:
1256 * 1. Hover enter on box 1
1257 * 2. Hover move into box 2
1258 * 3. Hover exit on box 2
1259 * 4. Outside event on box 3
1260 * 5. Down on box 2
1261 * 6. Up on box 2
1262 */
1263 @Test
1264 fun hoverAndClickMotionEvent_badOutsideMotionEvent_outsideMotionEventIgnored() {
1265 // --> Arrange
1266 var box1LayoutCoordinates: LayoutCoordinates? = null
1267 var box2LayoutCoordinates: LayoutCoordinates? = null
1268 var box3LayoutCoordinates: LayoutCoordinates? = null
1269
1270 val setUpFinishedLatch = CountDownLatch(4)
1271 // One less than total because outside is not sent to Compose.
1272 val totalEventLatch = CountDownLatch(5)
1273
1274 // Events for Box 1
1275 var enterBox1 = false
1276 var exitBox1 = false
1277
1278 // Events for Box 2
1279 var enterBox2 = false
1280 var exitBox2 = false
1281 var pressBox2 = false
1282 var releaseBox2 = false
1283
1284 // All other events that should never be triggered in this test
1285 var eventsThatShouldNotTrigger = false
1286
1287 var pointerEvent: PointerEvent? = null
1288
1289 rule.runOnUiThread {
1290 container.setContent {
1291 Column(
1292 Modifier.fillMaxSize()
1293 .onGloballyPositioned { setUpFinishedLatch.countDown() }
1294 .pointerInput(Unit) {
1295 awaitPointerEventScope {
1296 while (true) {
1297 awaitPointerEvent()
1298 totalEventLatch.countDown()
1299 }
1300 }
1301 }
1302 ) {
1303 // Box 1
1304 Box(
1305 Modifier.size(50.dp)
1306 .onGloballyPositioned {
1307 box1LayoutCoordinates = it
1308 setUpFinishedLatch.countDown()
1309 }
1310 .pointerInput(Unit) {
1311 awaitPointerEventScope {
1312 while (true) {
1313 pointerEvent = awaitPointerEvent()
1314
1315 when (pointerEvent!!.type) {
1316 PointerEventType.Enter -> {
1317 enterBox1 = true
1318 }
1319 PointerEventType.Exit -> {
1320 exitBox1 = true
1321 }
1322 else -> {
1323 eventsThatShouldNotTrigger = true
1324 }
1325 }
1326 }
1327 }
1328 }
1329 ) {}
1330
1331 // Box 2
1332 Box(
1333 Modifier.size(50.dp)
1334 .onGloballyPositioned {
1335 box2LayoutCoordinates = it
1336 setUpFinishedLatch.countDown()
1337 }
1338 .pointerInput(Unit) {
1339 awaitPointerEventScope {
1340 while (true) {
1341 pointerEvent = awaitPointerEvent()
1342
1343 when (pointerEvent!!.type) {
1344 PointerEventType.Enter -> {
1345 enterBox2 = true
1346 }
1347 PointerEventType.Press -> {
1348 pressBox2 = true
1349 }
1350 PointerEventType.Release -> {
1351 releaseBox2 = true
1352 }
1353 PointerEventType.Exit -> {
1354 exitBox2 = true
1355 }
1356 else -> {
1357 eventsThatShouldNotTrigger = true
1358 }
1359 }
1360 }
1361 }
1362 }
1363 ) {}
1364
1365 // Box 3
1366 Box(
1367 Modifier.size(50.dp)
1368 .onGloballyPositioned {
1369 box3LayoutCoordinates = it
1370 setUpFinishedLatch.countDown()
1371 }
1372 .pointerInput(Unit) {
1373 awaitPointerEventScope {
1374 while (true) {
1375 pointerEvent = awaitPointerEvent()
1376 eventsThatShouldNotTrigger = true
1377 }
1378 }
1379 }
1380 ) {}
1381 }
1382 }
1383 }
1384 // Ensure Arrange (setup) step is finished
1385 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
1386
1387 // --> Act + Assert (interwoven)
1388 // Hover Enter on Box 1
1389 dispatchMouseEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
1390 rule.runOnUiThread {
1391 assertThat(enterBox1).isTrue()
1392 assertThat(pointerEvent).isNotNull()
1393 assertThat(eventsThatShouldNotTrigger).isFalse()
1394 assertHoverEvent(pointerEvent!!, isEnter = true)
1395 }
1396
1397 // Hover Move to Box 2
1398 pointerEvent = null // Reset before each event
1399 dispatchMouseEvent(ACTION_HOVER_MOVE, box2LayoutCoordinates!!)
1400 rule.runOnUiThread {
1401 assertThat(exitBox1).isTrue()
1402 assertThat(enterBox2).isTrue()
1403 assertThat(pointerEvent).isNotNull()
1404 assertThat(eventsThatShouldNotTrigger).isFalse()
1405 assertHoverEvent(pointerEvent!!, isEnter = true)
1406 }
1407
1408 // Hover Exit on Box 2
1409 pointerEvent = null // Reset before each event
1410 dispatchMouseEvent(ACTION_HOVER_EXIT, box2LayoutCoordinates!!)
1411
1412 // Hover exit events in Compose are always delayed two frames to ensure Compose does not
1413 // trigger them if they are followed by a press in the next frame. This accounts for that.
1414 rule.waitForFutureFrame(2)
1415
1416 rule.runOnUiThread {
1417 assertThat(exitBox2).isTrue()
1418 assertThat(pointerEvent).isNotNull()
1419 assertThat(eventsThatShouldNotTrigger).isFalse()
1420 }
1421
1422 // Outside event with Box 3 coordinates
1423 pointerEvent = null // Reset before each event
1424 dispatchMouseEvent(ACTION_OUTSIDE, box3LayoutCoordinates!!)
1425
1426 // No Compose event should be triggered (b/299074463#comment31)
1427 rule.runOnUiThread {
1428 assertThat(eventsThatShouldNotTrigger).isFalse()
1429 assertThat(pointerEvent).isNull()
1430 }
1431
1432 // Press on Box 2
1433 pointerEvent = null // Reset before each event
1434 dispatchMouseEvent(ACTION_DOWN, box2LayoutCoordinates!!)
1435 rule.runOnUiThread {
1436 assertThat(pressBox2).isTrue()
1437 assertThat(eventsThatShouldNotTrigger).isFalse()
1438 assertThat(pointerEvent).isNotNull()
1439 }
1440
1441 // Release on Box 2
1442 pointerEvent = null // Reset before each event
1443 dispatchMouseEvent(ACTION_UP, box2LayoutCoordinates!!)
1444 rule.runOnUiThread {
1445 assertThat(releaseBox2).isTrue()
1446 assertThat(eventsThatShouldNotTrigger).isFalse()
1447 assertThat(pointerEvent).isNotNull()
1448 }
1449
1450 assertTrue(totalEventLatch.await(1, TimeUnit.SECONDS))
1451 }
1452
1453 /*
1454 * Tests that a bad ACTION_HOVER_EXIT MotionEvent is ignored in Compose when it directly
1455 * proceeds an ACTION_SCROLL MotionEvent. This happens in some versions of Android Studio when
1456 * mirroring is used (b/314269723).
1457 *
1458 * The event order of MotionEvents:
1459 * - Hover enter on box 1
1460 * - Hover exit on box 1 (bad event)
1461 * - Scroll on box 1
1462 */
1463 @Test
1464 fun scrollMotionEvent_proceededImmediatelyByHoverExit_shouldNotTriggerHoverExit() {
1465 // --> Arrange
1466 val scrollDelta = Offset(0.35f, 0.65f)
1467 var box1LayoutCoordinates: LayoutCoordinates? = null
1468
1469 val setUpFinishedLatch = CountDownLatch(4)
1470
1471 // Events for Box 1
1472 var enterBox1 = false
1473 var scrollBox1 = false
1474
1475 // All other events that should never be triggered in this test
1476 var eventsThatShouldNotTrigger = false
1477
1478 var pointerEvent: PointerEvent? = null
1479
1480 rule.runOnUiThread {
1481 container.setContent {
1482 Column(
1483 Modifier.fillMaxSize().onGloballyPositioned { setUpFinishedLatch.countDown() }
1484 ) {
1485 // Box 1
1486 Box(
1487 Modifier.size(50.dp)
1488 .onGloballyPositioned {
1489 box1LayoutCoordinates = it
1490 setUpFinishedLatch.countDown()
1491 }
1492 .pointerInput(Unit) {
1493 awaitPointerEventScope {
1494 while (true) {
1495 pointerEvent = awaitPointerEvent()
1496
1497 when (pointerEvent!!.type) {
1498 PointerEventType.Enter -> {
1499 enterBox1 = true
1500 }
1501 PointerEventType.Exit -> {
1502 enterBox1 = false
1503 }
1504 PointerEventType.Scroll -> {
1505 scrollBox1 = true
1506 }
1507 else -> {
1508 eventsThatShouldNotTrigger = true
1509 }
1510 }
1511 }
1512 }
1513 }
1514 ) {}
1515
1516 // Box 2
1517 Box(
1518 Modifier.size(50.dp)
1519 .onGloballyPositioned { setUpFinishedLatch.countDown() }
1520 .pointerInput(Unit) {
1521 awaitPointerEventScope {
1522 while (true) {
1523 pointerEvent = awaitPointerEvent()
1524 // Should never do anything with this UI element.
1525 eventsThatShouldNotTrigger = true
1526 }
1527 }
1528 }
1529 ) {}
1530
1531 // Box 3
1532 Box(
1533 Modifier.size(50.dp)
1534 .onGloballyPositioned { setUpFinishedLatch.countDown() }
1535 .pointerInput(Unit) {
1536 awaitPointerEventScope {
1537 while (true) {
1538 pointerEvent = awaitPointerEvent()
1539 // Should never do anything with this UI element.
1540 eventsThatShouldNotTrigger = true
1541 }
1542 }
1543 }
1544 ) {}
1545 }
1546 }
1547 }
1548 // Ensure Arrange (setup) step is finished
1549 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
1550
1551 // --> Act + Assert (interwoven)
1552 // Hover Enter on Box 1
1553 dispatchMouseEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
1554 rule.runOnUiThread {
1555 assertThat(enterBox1).isTrue()
1556 assertThat(pointerEvent).isNotNull()
1557 assertThat(eventsThatShouldNotTrigger).isFalse()
1558 assertHoverEvent(pointerEvent!!, isEnter = true)
1559 }
1560
1561 // We do not use dispatchMouseEvent() to dispatch the following two events, because the
1562 // actions need to be executed in immediate succession
1563 rule.runOnUiThread {
1564 val root = box1LayoutCoordinates!!.findRootCoordinates()
1565 val pos = root.localPositionOf(box1LayoutCoordinates!!, Offset.Zero)
1566
1567 // Bad hover exit event on Box 1
1568 val exitMotionEvent =
1569 MotionEvent(
1570 0,
1571 ACTION_HOVER_EXIT,
1572 1,
1573 0,
1574 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_MOUSE }),
1575 arrayOf(PointerCoords(pos.x, pos.y, Offset.Zero.x, Offset.Zero.y))
1576 )
1577
1578 // Main scroll event on Box 1
1579 val scrollMotionEvent =
1580 MotionEvent(
1581 0,
1582 ACTION_SCROLL,
1583 1,
1584 0,
1585 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_MOUSE }),
1586 arrayOf(PointerCoords(pos.x, pos.y, scrollDelta.x, scrollDelta.y))
1587 )
1588
1589 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1590 androidComposeView.dispatchHoverEvent(exitMotionEvent)
1591 androidComposeView.dispatchGenericMotionEvent(scrollMotionEvent)
1592 }
1593
1594 rule.runOnUiThread {
1595 assertThat(enterBox1).isTrue()
1596 assertThat(scrollBox1).isTrue()
1597 assertThat(pointerEvent).isNotNull()
1598 assertThat(eventsThatShouldNotTrigger).isFalse()
1599 }
1600 }
1601
1602 /*
1603 * Tests that all valid combinations of MotionEvent.CLASSIFICATION_* are returned from
1604 * Compose's [PointerInput].
1605 * NOTE 1: We do NOT test invalid MotionEvent Classifications, because you can actually pass an
1606 * invalid classification value to [MotionEvent.obtain()] and it is not rejected. Therefore,
1607 * to maintain the same behavior, we just return whatever is set in [MotionEvent].
1608 * NOTE 2: The [MotionEvent.obtain()] that allows you to set classification, is only available
1609 * in U. (Thus, why this test request at least that version.)
1610 */
1611 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1612 @Test
1613 fun motionEventDispatch_withValidClassification_shouldMatchInPointerEvent() {
1614 // --> Arrange
1615 var boxLayoutCoordinates: LayoutCoordinates? = null
1616 val setUpFinishedLatch = CountDownLatch(1)
1617 var motionEventClassification = MotionEvent.CLASSIFICATION_NONE
1618 var pointerEvent: PointerEvent? = null
1619
1620 rule.runOnUiThread {
1621 container.setContent {
1622 Box(
1623 Modifier.fillMaxSize()
1624 .onGloballyPositioned {
1625 setUpFinishedLatch.countDown()
1626 boxLayoutCoordinates = it
1627 }
1628 .pointerInput(Unit) {
1629 awaitPointerEventScope {
1630 while (true) {
1631 pointerEvent = awaitPointerEvent()
1632 }
1633 }
1634 }
1635 ) {}
1636 }
1637 }
1638
1639 // Ensure Arrange (setup) step is finished
1640 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
1641
1642 // Set up values to be used for creation of all MotionEvents.
1643 var position: Offset?
1644 var eventTime = 0
1645 val numPointers = 1
1646 val actionIndex = 0
1647 val pointerProperties =
1648 arrayOf(PointerProperties(0).also { it.toolType = MotionEvent.TOOL_TYPE_FINGER })
1649 var pointerCoords: Array<PointerCoords>? = null
1650 val buttonState = 0
1651
1652 // --> Act
1653 rule.runOnUiThread {
1654 // Set up pointerCoords to be used for the rest of the events
1655 val root = boxLayoutCoordinates!!.findRootCoordinates()
1656 position = root.localPositionOf(boxLayoutCoordinates!!, Offset.Zero)
1657 pointerCoords =
1658 arrayOf(PointerCoords(position!!.x, position!!.y, Offset.Zero.x, Offset.Zero.y))
1659
1660 val downEvent =
1661 MotionEvent(
1662 eventTime = eventTime,
1663 action = ACTION_DOWN,
1664 numPointers = numPointers,
1665 actionIndex = actionIndex,
1666 pointerProperties = pointerProperties,
1667 pointerCoords = pointerCoords!!,
1668 buttonState = buttonState,
1669 classification = motionEventClassification
1670 )
1671
1672 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1673 androidComposeView.dispatchTouchEvent(downEvent)
1674 }
1675
1676 // --> Assert
1677 rule.runOnUiThread {
1678 assertThat(pointerEvent).isNotNull()
1679 // This will be MotionEvent.CLASSIFICATION_NONE (set in the beginning).
1680 assertThat(pointerEvent!!.classification).isEqualTo(motionEventClassification)
1681 }
1682
1683 eventTime += 500
1684 motionEventClassification = MotionEvent.CLASSIFICATION_AMBIGUOUS_GESTURE
1685
1686 // --> Act
1687 rule.runOnUiThread {
1688 val upEvent =
1689 MotionEvent(
1690 eventTime = eventTime,
1691 action = ACTION_UP,
1692 numPointers = numPointers,
1693 actionIndex = actionIndex,
1694 pointerProperties = pointerProperties,
1695 pointerCoords = pointerCoords!!,
1696 buttonState = buttonState,
1697 classification = motionEventClassification
1698 )
1699
1700 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1701 androidComposeView.dispatchTouchEvent(upEvent)
1702 }
1703
1704 // --> Assert
1705 rule.runOnUiThread {
1706 assertThat(pointerEvent).isNotNull()
1707 assertThat(pointerEvent!!.classification).isEqualTo(motionEventClassification)
1708 }
1709
1710 eventTime += 500
1711 motionEventClassification = MotionEvent.CLASSIFICATION_DEEP_PRESS
1712
1713 // --> Act
1714 rule.runOnUiThread {
1715 val downEvent =
1716 MotionEvent(
1717 eventTime = eventTime,
1718 action = ACTION_DOWN,
1719 numPointers = numPointers,
1720 actionIndex = actionIndex,
1721 pointerProperties = pointerProperties,
1722 pointerCoords = pointerCoords!!,
1723 buttonState = buttonState,
1724 classification = motionEventClassification
1725 )
1726
1727 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1728 androidComposeView.dispatchTouchEvent(downEvent)
1729 }
1730
1731 // --> Assert
1732 rule.runOnUiThread {
1733 assertThat(pointerEvent).isNotNull()
1734 assertThat(pointerEvent!!.classification).isEqualTo(motionEventClassification)
1735 }
1736
1737 eventTime += 500
1738 motionEventClassification = MotionEvent.CLASSIFICATION_TWO_FINGER_SWIPE
1739
1740 // --> Act
1741 rule.runOnUiThread {
1742 val upEvent =
1743 MotionEvent(
1744 eventTime = eventTime,
1745 action = ACTION_UP,
1746 numPointers = numPointers,
1747 actionIndex = actionIndex,
1748 pointerProperties = pointerProperties,
1749 pointerCoords = pointerCoords!!,
1750 buttonState = buttonState,
1751 classification = motionEventClassification
1752 )
1753
1754 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1755 androidComposeView.dispatchTouchEvent(upEvent)
1756 }
1757
1758 // --> Assert
1759 rule.runOnUiThread {
1760 assertThat(pointerEvent).isNotNull()
1761 assertThat(pointerEvent!!.classification).isEqualTo(motionEventClassification)
1762 }
1763
1764 eventTime += 500
1765 motionEventClassification = MotionEvent.CLASSIFICATION_PINCH
1766
1767 // --> Act
1768 rule.runOnUiThread {
1769 val downEvent =
1770 MotionEvent(
1771 eventTime = eventTime,
1772 action = ACTION_DOWN,
1773 numPointers = numPointers,
1774 actionIndex = actionIndex,
1775 pointerProperties = pointerProperties,
1776 pointerCoords = pointerCoords!!,
1777 buttonState = buttonState,
1778 classification = motionEventClassification
1779 )
1780
1781 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
1782 androidComposeView.dispatchTouchEvent(downEvent)
1783 }
1784
1785 // --> Assert
1786 rule.runOnUiThread {
1787 assertThat(pointerEvent).isNotNull()
1788 assertThat(pointerEvent!!.classification).isEqualTo(motionEventClassification)
1789 }
1790 }
1791
1792 /*
1793 * Tests that [PointerEvent] without a [MotionEvent] will return a NONE classification.
1794 */
1795 @Test
1796 fun pointerInput_withoutMotionEvent_classificationShouldBeNone() {
1797 val pointerEventWithoutMotionEvent = PointerEvent(listOf(), internalPointerEvent = null)
1798
1799 rule.runOnUiThread {
1800 assertThat(pointerEventWithoutMotionEvent.classification)
1801 .isEqualTo(MotionEvent.CLASSIFICATION_NONE)
1802 }
1803 }
1804
1805 /*
1806 * Tests alternating between hover TOUCH events and touch events across multiple UI elements.
1807 * Specifically, to recreate Talkback events.
1808 *
1809 * Important Note: Usually a hover MotionEvent sent from Android has the tool type set as
1810 * [MotionEvent.TOOL_TYPE_MOUSE]. However, Talkback sets the tool type to
1811 * [MotionEvent.TOOL_TYPE_FINGER]. We do that in this test by calling the
1812 * [dispatchTouchEvent()] instead of [dispatchMouseEvent()].
1813 *
1814 * Specific events:
1815 * 1. UI Element 1: ENTER (hover enter [touch])
1816 * 2. UI Element 1: EXIT (hover exit [touch])
1817 * 3. UI Element 1: PRESS (touch)
1818 * 4. UI Element 1: RELEASE (touch)
1819 * 5. UI Element 2: PRESS (touch)
1820 * 6. UI Element 2: RELEASE (touch)
1821 *
1822 * Should NOT trigger any additional events (like an extra press or exit)!
1823 */
1824 @Test
1825 fun alternatingHoverAndTouch_hoverUi1ToTouchUi1ToTouchUi2_shouldNotTriggerAdditionalEvents() {
1826 // --> Arrange
1827 var box1LayoutCoordinates: LayoutCoordinates? = null
1828 var box2LayoutCoordinates: LayoutCoordinates? = null
1829
1830 val setUpFinishedLatch = CountDownLatch(4)
1831
1832 var eventTime = 100
1833
1834 // Events for Box 1
1835 var box1HoverEnter = 0
1836 var box1HoverExit = 0
1837 var box1Down = 0
1838 var box1Up = 0
1839
1840 // Events for Box 2
1841 var box2Down = 0
1842 var box2Up = 0
1843
1844 // All other events that should never be triggered in this test
1845 var eventsThatShouldNotTrigger = false
1846
1847 var pointerEvent: PointerEvent? = null
1848
1849 rule.runOnUiThread {
1850 container.setContent {
1851 Column(
1852 Modifier.fillMaxSize().onGloballyPositioned { setUpFinishedLatch.countDown() }
1853 ) {
1854 // Box 1
1855 Box(
1856 Modifier.size(50.dp)
1857 .onGloballyPositioned {
1858 box1LayoutCoordinates = it
1859 setUpFinishedLatch.countDown()
1860 }
1861 .pointerInput(Unit) {
1862 awaitPointerEventScope {
1863 while (true) {
1864 pointerEvent = awaitPointerEvent()
1865
1866 when (pointerEvent!!.type) {
1867 PointerEventType.Enter -> {
1868 ++box1HoverEnter
1869 }
1870 PointerEventType.Press -> {
1871 ++box1Down
1872 }
1873 PointerEventType.Release -> {
1874 ++box1Up
1875 }
1876 PointerEventType.Exit -> {
1877 ++box1HoverExit
1878 }
1879 else -> {
1880 eventsThatShouldNotTrigger = true
1881 }
1882 }
1883 }
1884 }
1885 }
1886 ) {}
1887
1888 // Box 2
1889 Box(
1890 Modifier.size(50.dp)
1891 .onGloballyPositioned {
1892 box2LayoutCoordinates = it
1893 setUpFinishedLatch.countDown()
1894 }
1895 .pointerInput(Unit) {
1896 awaitPointerEventScope {
1897 while (true) {
1898 pointerEvent = awaitPointerEvent()
1899
1900 when (pointerEvent!!.type) {
1901 PointerEventType.Press -> {
1902 ++box2Down
1903 }
1904 PointerEventType.Release -> {
1905 ++box2Up
1906 }
1907 else -> {
1908 eventsThatShouldNotTrigger = true
1909 }
1910 }
1911 }
1912 }
1913 }
1914 ) {}
1915
1916 // Box 3
1917 Box(
1918 Modifier.size(50.dp)
1919 .onGloballyPositioned { setUpFinishedLatch.countDown() }
1920 .pointerInput(Unit) {
1921 awaitPointerEventScope {
1922 while (true) {
1923 pointerEvent = awaitPointerEvent()
1924 // Should never do anything with this UI element.
1925 eventsThatShouldNotTrigger = true
1926 }
1927 }
1928 }
1929 ) {}
1930 }
1931 }
1932 }
1933 // Ensure Arrange (setup) step is finished
1934 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
1935
1936 // --> Act + Assert (interwoven)
1937 // Hover Enter on Box 1
1938 dispatchTouchEvent(
1939 action = ACTION_HOVER_ENTER,
1940 layoutCoordinates = box1LayoutCoordinates!!,
1941 eventTime = eventTime
1942 )
1943 rule.runOnUiThread {
1944 // Verify Box 1 events
1945 assertThat(box1HoverEnter).isEqualTo(1)
1946 assertThat(box1HoverExit).isEqualTo(0)
1947 assertThat(box1Down).isEqualTo(0)
1948 assertThat(box1Up).isEqualTo(0)
1949
1950 // Verify Box 2 events
1951 assertThat(box2Down).isEqualTo(0)
1952 assertThat(box2Up).isEqualTo(0)
1953
1954 assertThat(pointerEvent).isNotNull()
1955 assertThat(eventsThatShouldNotTrigger).isFalse()
1956 assertHoverEvent(pointerEvent!!, isEnter = true)
1957 }
1958
1959 // Hover Exit on Box 1
1960 pointerEvent = null // Reset before each event
1961 eventTime += 100
1962 dispatchTouchEvent(
1963 action = ACTION_HOVER_EXIT,
1964 layoutCoordinates = box1LayoutCoordinates!!,
1965 eventTime = eventTime
1966 )
1967
1968 rule.waitForFutureFrame(2)
1969
1970 rule.runOnUiThread {
1971 // Verify Box 1 events
1972 assertThat(box1HoverEnter).isEqualTo(1)
1973 assertThat(box1HoverExit).isEqualTo(1)
1974 assertThat(box1Down).isEqualTo(0)
1975 assertThat(box1Up).isEqualTo(0)
1976
1977 // Verify Box 2 events
1978 assertThat(box2Down).isEqualTo(0)
1979 assertThat(box2Up).isEqualTo(0)
1980
1981 assertThat(pointerEvent).isNotNull()
1982 assertThat(eventsThatShouldNotTrigger).isFalse()
1983 }
1984
1985 // Press on Box 1
1986 pointerEvent = null // Reset before each event
1987 eventTime += 100
1988 dispatchTouchEvent(
1989 action = ACTION_DOWN,
1990 layoutCoordinates = box1LayoutCoordinates!!,
1991 eventTime = eventTime
1992 )
1993 rule.runOnUiThread {
1994 // Verify Box 1 events
1995 assertThat(box1HoverEnter).isEqualTo(1)
1996 assertThat(box1HoverExit).isEqualTo(1)
1997 assertThat(box1Down).isEqualTo(1)
1998 assertThat(box1Up).isEqualTo(0)
1999
2000 // Verify Box 2 events
2001 assertThat(box2Down).isEqualTo(0)
2002 assertThat(box2Up).isEqualTo(0)
2003
2004 assertThat(pointerEvent).isNotNull()
2005 assertThat(eventsThatShouldNotTrigger).isFalse()
2006 }
2007
2008 // Release on Box 1
2009 pointerEvent = null // Reset before each event
2010 eventTime += 100
2011 dispatchTouchEvent(
2012 action = ACTION_UP,
2013 layoutCoordinates = box1LayoutCoordinates!!,
2014 eventTime = eventTime
2015 )
2016 rule.runOnUiThread {
2017 // Verify Box 1 events
2018 assertThat(box1HoverEnter).isEqualTo(1)
2019 assertThat(box1HoverExit).isEqualTo(1)
2020 assertThat(box1Down).isEqualTo(1)
2021 assertThat(box1Up).isEqualTo(1)
2022
2023 // Verify Box 2 events
2024 assertThat(box2Down).isEqualTo(0)
2025 assertThat(box2Up).isEqualTo(0)
2026
2027 assertThat(pointerEvent).isNotNull()
2028 assertThat(eventsThatShouldNotTrigger).isFalse()
2029 }
2030
2031 // Press on Box 2
2032 pointerEvent = null // Reset before each event
2033 eventTime += 100
2034 dispatchTouchEvent(
2035 action = ACTION_DOWN,
2036 layoutCoordinates = box2LayoutCoordinates!!,
2037 eventTime = eventTime
2038 )
2039 rule.runOnUiThread {
2040 // Verify Box 1 events
2041 assertThat(box1HoverEnter).isEqualTo(1)
2042 assertThat(box1HoverExit).isEqualTo(1)
2043 assertThat(box1Down).isEqualTo(1)
2044 assertThat(box1Up).isEqualTo(1)
2045
2046 // Verify Box 2 events
2047 assertThat(box2Down).isEqualTo(1)
2048 assertThat(box2Up).isEqualTo(0)
2049
2050 assertThat(pointerEvent).isNotNull()
2051 assertThat(eventsThatShouldNotTrigger).isFalse()
2052 }
2053
2054 // Press on Box 2
2055 pointerEvent = null // Reset before each event
2056 eventTime += 100
2057 dispatchTouchEvent(
2058 action = ACTION_UP,
2059 layoutCoordinates = box2LayoutCoordinates!!,
2060 eventTime = eventTime
2061 )
2062 rule.runOnUiThread {
2063 // Verify Box 1 events
2064 assertThat(box1HoverEnter).isEqualTo(1)
2065 assertThat(box1HoverExit).isEqualTo(1)
2066 assertThat(box1Down).isEqualTo(1)
2067 assertThat(box1Up).isEqualTo(1)
2068
2069 // Verify Box 2 events
2070 assertThat(box2Down).isEqualTo(1)
2071 assertThat(box2Up).isEqualTo(1)
2072
2073 assertThat(pointerEvent).isNotNull()
2074 assertThat(eventsThatShouldNotTrigger).isFalse()
2075 }
2076 }
2077
2078 /*
2079 * Tests alternating between hover TOUCH events and regular touch events across multiple
2080 * UI elements. Specifically, to recreate Talkback events.
2081 *
2082 * Important Note: Usually a hover MotionEvent sent from Android has the tool type set as
2083 * [MotionEvent.TOOL_TYPE_MOUSE]. However, Talkback sets the tool type to
2084 * [MotionEvent.TOOL_TYPE_FINGER]. We do that in this test by calling the
2085 * [dispatchTouchEvent()] instead of [dispatchMouseEvent()].
2086 *
2087 * Specific events:
2088 * 1. UI Element 1: ENTER (hover enter [touch])
2089 * 2. UI Element 1: EXIT (hover exit [touch])
2090 * 5. UI Element 2: PRESS (touch)
2091 * 6. UI Element 2: RELEASE (touch)
2092 *
2093 * Should NOT trigger any additional events (like an extra exit)!
2094 */
2095 @Test
2096 fun alternatingHoverAndTouch_hoverUi1ToTouchUi2_shouldNotTriggerAdditionalEvents() {
2097 // --> Arrange
2098 var box1LayoutCoordinates: LayoutCoordinates? = null
2099 var box2LayoutCoordinates: LayoutCoordinates? = null
2100
2101 val setUpFinishedLatch = CountDownLatch(4)
2102
2103 var eventTime = 100
2104
2105 // Events for Box 1
2106 var box1HoverEnter = 0
2107 var box1HoverExit = 0
2108
2109 // Events for Box 2
2110 var box2Down = 0
2111 var box2Up = 0
2112
2113 // All other events that should never be triggered in this test
2114 var eventsThatShouldNotTrigger = false
2115
2116 var pointerEvent: PointerEvent? = null
2117
2118 rule.runOnUiThread {
2119 container.setContent {
2120 Column(
2121 Modifier.fillMaxSize().onGloballyPositioned { setUpFinishedLatch.countDown() }
2122 ) {
2123 // Box 1
2124 Box(
2125 Modifier.size(50.dp)
2126 .onGloballyPositioned {
2127 box1LayoutCoordinates = it
2128 setUpFinishedLatch.countDown()
2129 }
2130 .pointerInput(Unit) {
2131 awaitPointerEventScope {
2132 while (true) {
2133 pointerEvent = awaitPointerEvent()
2134
2135 when (pointerEvent!!.type) {
2136 PointerEventType.Enter -> {
2137 ++box1HoverEnter
2138 }
2139 PointerEventType.Exit -> {
2140 ++box1HoverExit
2141 }
2142 else -> {
2143 eventsThatShouldNotTrigger = true
2144 }
2145 }
2146 }
2147 }
2148 }
2149 ) {}
2150
2151 // Box 2
2152 Box(
2153 Modifier.size(50.dp)
2154 .onGloballyPositioned {
2155 box2LayoutCoordinates = it
2156 setUpFinishedLatch.countDown()
2157 }
2158 .pointerInput(Unit) {
2159 awaitPointerEventScope {
2160 while (true) {
2161 pointerEvent = awaitPointerEvent()
2162
2163 when (pointerEvent!!.type) {
2164 PointerEventType.Press -> {
2165 ++box2Down
2166 }
2167 PointerEventType.Release -> {
2168 ++box2Up
2169 }
2170 else -> {
2171 eventsThatShouldNotTrigger = true
2172 }
2173 }
2174 }
2175 }
2176 }
2177 ) {}
2178
2179 // Box 3
2180 Box(
2181 Modifier.size(50.dp)
2182 .onGloballyPositioned { setUpFinishedLatch.countDown() }
2183 .pointerInput(Unit) {
2184 awaitPointerEventScope {
2185 while (true) {
2186 pointerEvent = awaitPointerEvent()
2187 // Should never do anything with this UI element.
2188 eventsThatShouldNotTrigger = true
2189 }
2190 }
2191 }
2192 ) {}
2193 }
2194 }
2195 }
2196 // Ensure Arrange (setup) step is finished
2197 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
2198
2199 // --> Act + Assert (interwoven)
2200 // Hover Enter on Box 1
2201 dispatchTouchEvent(
2202 action = ACTION_HOVER_ENTER,
2203 layoutCoordinates = box1LayoutCoordinates!!,
2204 eventTime = eventTime
2205 )
2206 rule.runOnUiThread {
2207 // Verify Box 1 events
2208 assertThat(box1HoverEnter).isEqualTo(1)
2209 assertThat(box1HoverExit).isEqualTo(0)
2210
2211 // Verify Box 2 events
2212 assertThat(box2Down).isEqualTo(0)
2213 assertThat(box2Up).isEqualTo(0)
2214
2215 assertThat(pointerEvent).isNotNull()
2216 assertThat(eventsThatShouldNotTrigger).isFalse()
2217 assertHoverEvent(pointerEvent!!, isEnter = true)
2218 }
2219
2220 // Hover Exit on Box 1
2221 pointerEvent = null // Reset before each event
2222 eventTime += 100
2223 dispatchTouchEvent(
2224 action = ACTION_HOVER_EXIT,
2225 layoutCoordinates = box1LayoutCoordinates!!,
2226 eventTime = eventTime
2227 )
2228
2229 rule.waitForFutureFrame(2)
2230
2231 rule.runOnUiThread {
2232 // Verify Box 1 events
2233 assertThat(box1HoverEnter).isEqualTo(1)
2234 assertThat(box1HoverExit).isEqualTo(1)
2235 // Verify Box 2 events
2236 assertThat(box2Down).isEqualTo(0)
2237 assertThat(box2Up).isEqualTo(0)
2238
2239 assertThat(pointerEvent).isNotNull()
2240 assertThat(eventsThatShouldNotTrigger).isFalse()
2241 }
2242
2243 // Press on Box 2
2244 pointerEvent = null // Reset before each event
2245 eventTime += 100
2246 dispatchTouchEvent(
2247 action = ACTION_DOWN,
2248 layoutCoordinates = box2LayoutCoordinates!!,
2249 eventTime = eventTime
2250 )
2251 rule.runOnUiThread {
2252 // Verify Box 1 events
2253 assertThat(box1HoverEnter).isEqualTo(1)
2254 assertThat(box1HoverExit).isEqualTo(1)
2255
2256 // Verify Box 2 events
2257 assertThat(box2Down).isEqualTo(1)
2258 assertThat(box2Up).isEqualTo(0)
2259
2260 assertThat(pointerEvent).isNotNull()
2261 assertThat(eventsThatShouldNotTrigger).isFalse()
2262 }
2263
2264 // Press on Box 2
2265 pointerEvent = null // Reset before each event
2266 eventTime += 100
2267 dispatchTouchEvent(
2268 action = ACTION_UP,
2269 layoutCoordinates = box2LayoutCoordinates!!,
2270 eventTime = eventTime
2271 )
2272 rule.runOnUiThread {
2273 // Verify Box 1 events
2274 assertThat(box1HoverEnter).isEqualTo(1)
2275 assertThat(box1HoverExit).isEqualTo(1)
2276
2277 // Verify Box 2 events
2278 assertThat(box2Down).isEqualTo(1)
2279 assertThat(box2Up).isEqualTo(1)
2280
2281 assertThat(pointerEvent).isNotNull()
2282 assertThat(eventsThatShouldNotTrigger).isFalse()
2283 }
2284 }
2285
2286 /*
2287 * Tests alternating hover TOUCH events across multiple UI elements. Specifically, to recreate
2288 * Talkback events.
2289 *
2290 * Important Note: Usually a hover MotionEvent sent from Android has the tool type set as
2291 * [MotionEvent.TOOL_TYPE_MOUSE]. However, Talkback sets the tool type to
2292 * [MotionEvent.TOOL_TYPE_FINGER]. We do that in this test by calling the
2293 * [dispatchTouchEvent()] instead of [dispatchMouseEvent()].
2294 *
2295 * Specific events:
2296 * 1. UI Element 1: ENTER (hover enter [touch])
2297 * 2. UI Element 1: EXIT (hover exit [touch])
2298 * 5. UI Element 2: ENTER (hover enter [touch])
2299 * 6. UI Element 2: EXIT (hover exit [touch])
2300 *
2301 * Should NOT trigger any additional events (like an extra exit)!
2302 */
2303 @Test
2304 fun hoverEventsBetweenUIElements_hoverUi1ToHoverUi2_shouldNotTriggerAdditionalEvents() {
2305 // --> Arrange
2306 var box1LayoutCoordinates: LayoutCoordinates? = null
2307 var box2LayoutCoordinates: LayoutCoordinates? = null
2308
2309 val setUpFinishedLatch = CountDownLatch(4)
2310
2311 // Events for Box 1
2312 var box1HoverEnter = 0
2313 var box1HoverExit = 0
2314
2315 // Events for Box 2
2316 var box2HoverEnter = 0
2317 var box2HoverExit = 0
2318
2319 // All other events that should never be triggered in this test
2320 var eventsThatShouldNotTrigger = false
2321
2322 var pointerEvent: PointerEvent? = null
2323
2324 rule.runOnUiThread {
2325 container.setContent {
2326 Column(
2327 Modifier.fillMaxSize().onGloballyPositioned { setUpFinishedLatch.countDown() }
2328 ) {
2329 // Box 1
2330 Box(
2331 Modifier.size(50.dp)
2332 .onGloballyPositioned {
2333 box1LayoutCoordinates = it
2334 setUpFinishedLatch.countDown()
2335 }
2336 .pointerInput(Unit) {
2337 awaitPointerEventScope {
2338 while (true) {
2339 pointerEvent = awaitPointerEvent()
2340
2341 when (pointerEvent!!.type) {
2342 PointerEventType.Enter -> {
2343 ++box1HoverEnter
2344 }
2345 PointerEventType.Exit -> {
2346 ++box1HoverExit
2347 }
2348 else -> {
2349 eventsThatShouldNotTrigger = true
2350 }
2351 }
2352 }
2353 }
2354 }
2355 ) {}
2356
2357 // Box 2
2358 Box(
2359 Modifier.size(50.dp)
2360 .onGloballyPositioned {
2361 box2LayoutCoordinates = it
2362 setUpFinishedLatch.countDown()
2363 }
2364 .pointerInput(Unit) {
2365 awaitPointerEventScope {
2366 while (true) {
2367 pointerEvent = awaitPointerEvent()
2368
2369 when (pointerEvent!!.type) {
2370 PointerEventType.Enter -> {
2371 ++box2HoverEnter
2372 }
2373 PointerEventType.Exit -> {
2374 ++box2HoverExit
2375 }
2376 else -> {
2377 eventsThatShouldNotTrigger = true
2378 }
2379 }
2380 }
2381 }
2382 }
2383 ) {}
2384
2385 // Box 3
2386 Box(
2387 Modifier.size(50.dp)
2388 .onGloballyPositioned { setUpFinishedLatch.countDown() }
2389 .pointerInput(Unit) {
2390 awaitPointerEventScope {
2391 while (true) {
2392 pointerEvent = awaitPointerEvent()
2393 // Should never do anything with this UI element.
2394 eventsThatShouldNotTrigger = true
2395 }
2396 }
2397 }
2398 ) {}
2399 }
2400 }
2401 }
2402 // Ensure Arrange (setup) step is finished
2403 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
2404
2405 // --> Act + Assert (interwoven)
2406 // Hover Enter on Box 1
2407 dispatchTouchEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
2408 rule.runOnUiThread {
2409 // Verify Box 1 events
2410 assertThat(box1HoverEnter).isEqualTo(1)
2411 assertThat(box1HoverExit).isEqualTo(0)
2412
2413 // Verify Box 2 events
2414 assertThat(box2HoverEnter).isEqualTo(0)
2415 assertThat(box2HoverExit).isEqualTo(0)
2416
2417 assertThat(pointerEvent).isNotNull()
2418 assertThat(eventsThatShouldNotTrigger).isFalse()
2419 assertHoverEvent(pointerEvent!!, isEnter = true)
2420 }
2421
2422 // Hover Exit on Box 1
2423 pointerEvent = null // Reset before each event
2424 dispatchTouchEvent(ACTION_HOVER_EXIT, box1LayoutCoordinates!!)
2425
2426 rule.waitForFutureFrame(2)
2427
2428 rule.runOnUiThread {
2429 // Verify Box 1 events
2430 assertThat(box1HoverEnter).isEqualTo(1)
2431 assertThat(box1HoverExit).isEqualTo(1)
2432 // Verify Box 2 events
2433 assertThat(box2HoverEnter).isEqualTo(0)
2434 assertThat(box2HoverExit).isEqualTo(0)
2435
2436 assertThat(pointerEvent).isNotNull()
2437 assertThat(eventsThatShouldNotTrigger).isFalse()
2438 }
2439
2440 // Press on Box 2
2441 pointerEvent = null // Reset before each event
2442 dispatchTouchEvent(ACTION_HOVER_ENTER, box2LayoutCoordinates!!)
2443 rule.runOnUiThread {
2444 // Verify Box 1 events
2445 assertThat(box1HoverEnter).isEqualTo(1)
2446 assertThat(box1HoverExit).isEqualTo(1)
2447
2448 // Verify Box 2 events
2449 assertThat(box2HoverEnter).isEqualTo(1)
2450 assertThat(box2HoverExit).isEqualTo(0)
2451
2452 assertThat(pointerEvent).isNotNull()
2453 assertThat(eventsThatShouldNotTrigger).isFalse()
2454 }
2455
2456 // Press on Box 2
2457 pointerEvent = null // Reset before each event
2458 dispatchTouchEvent(ACTION_HOVER_EXIT, box2LayoutCoordinates!!)
2459
2460 rule.waitForFutureFrame(2)
2461
2462 rule.runOnUiThread {
2463 // Verify Box 1 events
2464 assertThat(box1HoverEnter).isEqualTo(1)
2465 assertThat(box1HoverExit).isEqualTo(1)
2466
2467 // Verify Box 2 events
2468 assertThat(box2HoverEnter).isEqualTo(1)
2469 assertThat(box2HoverExit).isEqualTo(1)
2470
2471 assertThat(pointerEvent).isNotNull()
2472 assertThat(eventsThatShouldNotTrigger).isFalse()
2473 }
2474 }
2475
2476 /*
2477 * Tests TOUCH events are triggered correctly when dynamically adding a NON-pointer input
2478 * modifier above an existing pointer input modifier.
2479 *
2480 * Note: The lambda for the existing pointer input modifier is not re-executed after the
2481 * dynamic one is added.
2482 *
2483 * Specific events:
2484 * 1. UI Element (modifier 1 only): PRESS (touch)
2485 * 2. UI Element (modifier 1 only): MOVE (touch)
2486 * 3. UI Element (modifier 1 only): RELEASE (touch)
2487 * 4. Dynamically adds NON-pointer input modifier (between input event streams)
2488 * 5. UI Element (modifier 1 and 2): PRESS (touch)
2489 * 6. UI Element (modifier 1 and 2): MOVE (touch)
2490 * 7. UI Element (modifier 1 and 2): RELEASE (touch)
2491 */
2492 @Test
2493 fun dynamicNonInputModifier_addsAboveExistingModifier_shouldTriggerInNewModifier() {
2494 // --> Arrange
2495 val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
2496 var box1LayoutCoordinates: LayoutCoordinates? = null
2497
2498 val setUpFinishedLatch = CountDownLatch(1)
2499
2500 var enableDynamicPointerInput by mutableStateOf(false)
2501
2502 // Events for the lower modifier Box 1
2503 var originalPointerInputScopeExecutionCount by mutableStateOf(0)
2504 var preexistingModifierPress by mutableStateOf(0)
2505 var preexistingModifierMove by mutableStateOf(0)
2506 var preexistingModifierRelease by mutableStateOf(0)
2507
2508 // All other events that should never be triggered in this test
2509 var eventsThatShouldNotTrigger by mutableStateOf(false)
2510
2511 var pointerEvent: PointerEvent? by mutableStateOf(null)
2512
2513 // Events for the dynamic upper modifier Box 1
2514 var dynamicModifierExecuted by mutableStateOf(false)
2515
2516 // Non-Pointer Input Modifier that is toggled on/off based on passed value.
2517 fun Modifier.dynamicallyToggledModifier(enable: Boolean) =
2518 if (enable) {
2519 dynamicModifierExecuted = true
2520 background(Color.Green)
2521 } else this
2522
2523 // Setup UI
2524 rule.runOnUiThread {
2525 container.setContent {
2526 Box(
2527 Modifier.size(200.dp)
2528 .onGloballyPositioned {
2529 box1LayoutCoordinates = it
2530 setUpFinishedLatch.countDown()
2531 }
2532 .dynamicallyToggledModifier(enableDynamicPointerInput)
2533 .pointerInput(originalPointerInputModifierKey) {
2534 ++originalPointerInputScopeExecutionCount
2535 // Reset pointer events when lambda is ran the first time
2536 preexistingModifierPress = 0
2537 preexistingModifierMove = 0
2538 preexistingModifierRelease = 0
2539
2540 awaitPointerEventScope {
2541 while (true) {
2542 pointerEvent = awaitPointerEvent()
2543 when (pointerEvent!!.type) {
2544 PointerEventType.Press -> {
2545 ++preexistingModifierPress
2546 }
2547 PointerEventType.Move -> {
2548 ++preexistingModifierMove
2549 }
2550 PointerEventType.Release -> {
2551 ++preexistingModifierRelease
2552 }
2553 else -> {
2554 eventsThatShouldNotTrigger = true
2555 }
2556 }
2557 }
2558 }
2559 }
2560 ) {}
2561 }
2562 }
2563 // Ensure Arrange (setup) step is finished
2564 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
2565
2566 // --> Act + Assert (interwoven)
2567 // DOWN (original modifier only)
2568 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
2569 rule.runOnUiThread {
2570 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2571 assertThat(dynamicModifierExecuted).isEqualTo(false)
2572
2573 // Verify Box 1 existing modifier events
2574 assertThat(preexistingModifierPress).isEqualTo(1)
2575 assertThat(preexistingModifierMove).isEqualTo(0)
2576 assertThat(preexistingModifierRelease).isEqualTo(0)
2577
2578 assertThat(pointerEvent).isNotNull()
2579 assertThat(eventsThatShouldNotTrigger).isFalse()
2580 }
2581
2582 // MOVE (original modifier only)
2583 dispatchTouchEvent(
2584 ACTION_MOVE,
2585 box1LayoutCoordinates!!,
2586 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2587 )
2588 rule.runOnUiThread {
2589 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2590 assertThat(dynamicModifierExecuted).isEqualTo(false)
2591
2592 // Verify Box 1 existing modifier events
2593 assertThat(preexistingModifierPress).isEqualTo(1)
2594 assertThat(preexistingModifierMove).isEqualTo(1)
2595 assertThat(preexistingModifierRelease).isEqualTo(0)
2596
2597 assertThat(pointerEvent).isNotNull()
2598 assertThat(eventsThatShouldNotTrigger).isFalse()
2599 }
2600
2601 // UP (original modifier only)
2602 dispatchTouchEvent(
2603 ACTION_UP,
2604 box1LayoutCoordinates!!,
2605 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2606 )
2607 rule.runOnUiThread {
2608 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2609 assertThat(dynamicModifierExecuted).isEqualTo(false)
2610
2611 // Verify Box 1 existing modifier events
2612 assertThat(preexistingModifierPress).isEqualTo(1)
2613 assertThat(preexistingModifierMove).isEqualTo(1)
2614 assertThat(preexistingModifierRelease).isEqualTo(1)
2615
2616 assertThat(pointerEvent).isNotNull()
2617 assertThat(eventsThatShouldNotTrigger).isFalse()
2618 }
2619 enableDynamicPointerInput = true
2620 rule.waitForFutureFrame(2)
2621
2622 // DOWN (original + dynamically added modifiers)
2623 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
2624
2625 rule.runOnUiThread {
2626 // There are no pointer input modifiers added above this pointer modifier, so the
2627 // same one is used.
2628 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2629 // The dynamic one has been added, so we execute its thing as well.
2630 assertThat(dynamicModifierExecuted).isEqualTo(true)
2631
2632 // Verify Box 1 existing modifier events
2633 assertThat(preexistingModifierPress).isEqualTo(2)
2634 assertThat(preexistingModifierMove).isEqualTo(1)
2635 assertThat(preexistingModifierRelease).isEqualTo(1)
2636
2637 assertThat(pointerEvent).isNotNull()
2638 assertThat(eventsThatShouldNotTrigger).isFalse()
2639 }
2640
2641 // MOVE (original + dynamically added modifiers)
2642 dispatchTouchEvent(
2643 ACTION_MOVE,
2644 box1LayoutCoordinates!!,
2645 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2646 )
2647 rule.runOnUiThread {
2648 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2649 assertThat(dynamicModifierExecuted).isEqualTo(true)
2650
2651 // Verify Box 1 existing modifier events
2652 assertThat(preexistingModifierPress).isEqualTo(2)
2653 assertThat(preexistingModifierMove).isEqualTo(2)
2654 assertThat(preexistingModifierRelease).isEqualTo(1)
2655
2656 assertThat(pointerEvent).isNotNull()
2657 assertThat(eventsThatShouldNotTrigger).isFalse()
2658 }
2659
2660 // UP (original + dynamically added modifiers)
2661 dispatchTouchEvent(
2662 ACTION_UP,
2663 box1LayoutCoordinates!!,
2664 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2665 )
2666 rule.runOnUiThread {
2667 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2668 assertThat(dynamicModifierExecuted).isEqualTo(true)
2669
2670 // Verify Box 1 existing modifier events
2671 assertThat(preexistingModifierPress).isEqualTo(2)
2672 assertThat(preexistingModifierMove).isEqualTo(2)
2673 assertThat(preexistingModifierRelease).isEqualTo(2)
2674
2675 assertThat(pointerEvent).isNotNull()
2676 assertThat(eventsThatShouldNotTrigger).isFalse()
2677 }
2678 }
2679
2680 /*
2681 * Tests TOUCH events are triggered correctly when dynamically adding a pointer input modifier
2682 * ABOVE an existing pointer input modifier.
2683 *
2684 * Note: The lambda for the existing pointer input modifier **IS** re-executed after the
2685 * dynamic pointer input modifier is added above it.
2686 *
2687 * Specific events:
2688 * 1. UI Element (modifier 1 only): PRESS (touch)
2689 * 2. UI Element (modifier 1 only): MOVE (touch)
2690 * 3. UI Element (modifier 1 only): RELEASE (touch)
2691 * 4. Dynamically add pointer input modifier above existing one (between input event streams)
2692 * 5. UI Element (modifier 1 and 2): PRESS (touch)
2693 * 6. UI Element (modifier 1 and 2): MOVE (touch)
2694 * 7. UI Element (modifier 1 and 2): RELEASE (touch)
2695 */
2696 @Test
2697 fun dynamicInputModifierWithKey_addsAboveExistingModifier_shouldTriggerInNewModifier() {
2698 // --> Arrange
2699 val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
2700 var box1LayoutCoordinates: LayoutCoordinates? = null
2701
2702 val setUpFinishedLatch = CountDownLatch(1)
2703
2704 var enableDynamicPointerInput by mutableStateOf(false)
2705
2706 // Events for the lower modifier Box 1
2707 var originalPointerInputScopeExecutionCount by mutableStateOf(0)
2708 var preexistingModifierPress by mutableStateOf(0)
2709 var preexistingModifierMove by mutableStateOf(0)
2710 var preexistingModifierRelease by mutableStateOf(0)
2711
2712 // Events for the dynamic upper modifier Box 1
2713 var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
2714 var dynamicModifierPress by mutableStateOf(0)
2715 var dynamicModifierMove by mutableStateOf(0)
2716 var dynamicModifierRelease by mutableStateOf(0)
2717
2718 // All other events that should never be triggered in this test
2719 var eventsThatShouldNotTrigger by mutableStateOf(false)
2720
2721 var pointerEvent: PointerEvent? by mutableStateOf(null)
2722
2723 // Pointer Input Modifier that is toggled on/off based on passed value.
2724 fun Modifier.dynamicallyToggledPointerInput(
2725 enable: Boolean,
2726 pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
2727 ) =
2728 if (enable) {
2729 pointerInput(pointerEventLambda) {
2730 ++dynamicPointerInputScopeExecutionCount
2731
2732 // Reset pointer events when lambda is ran the first time
2733 dynamicModifierPress = 0
2734 dynamicModifierMove = 0
2735 dynamicModifierRelease = 0
2736
2737 awaitPointerEventScope {
2738 while (true) {
2739 pointerEventLambda(awaitPointerEvent())
2740 }
2741 }
2742 }
2743 } else this
2744
2745 // Setup UI
2746 rule.runOnUiThread {
2747 container.setContent {
2748 Box(
2749 Modifier.size(200.dp)
2750 .onGloballyPositioned {
2751 box1LayoutCoordinates = it
2752 setUpFinishedLatch.countDown()
2753 }
2754 .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
2755 when (it.type) {
2756 PointerEventType.Press -> {
2757 ++dynamicModifierPress
2758 }
2759 PointerEventType.Move -> {
2760 ++dynamicModifierMove
2761 }
2762 PointerEventType.Release -> {
2763 ++dynamicModifierRelease
2764 }
2765 else -> {
2766 eventsThatShouldNotTrigger = true
2767 }
2768 }
2769 }
2770 .pointerInput(originalPointerInputModifierKey) {
2771 ++originalPointerInputScopeExecutionCount
2772 // Reset pointer events when lambda is ran the first time
2773 preexistingModifierPress = 0
2774 preexistingModifierMove = 0
2775 preexistingModifierRelease = 0
2776
2777 awaitPointerEventScope {
2778 while (true) {
2779 pointerEvent = awaitPointerEvent()
2780 when (pointerEvent!!.type) {
2781 PointerEventType.Press -> {
2782 ++preexistingModifierPress
2783 }
2784 PointerEventType.Move -> {
2785 ++preexistingModifierMove
2786 }
2787 PointerEventType.Release -> {
2788 ++preexistingModifierRelease
2789 }
2790 else -> {
2791 eventsThatShouldNotTrigger = true
2792 }
2793 }
2794 }
2795 }
2796 }
2797 ) {}
2798 }
2799 }
2800 // Ensure Arrange (setup) step is finished
2801 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
2802
2803 // --> Act + Assert (interwoven)
2804 // DOWN (original modifier only)
2805 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
2806 rule.runOnUiThread {
2807 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2808 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
2809
2810 // Verify Box 1 existing modifier events
2811 assertThat(preexistingModifierPress).isEqualTo(1)
2812 assertThat(preexistingModifierMove).isEqualTo(0)
2813 assertThat(preexistingModifierRelease).isEqualTo(0)
2814
2815 // Verify Box 1 dynamically added modifier events
2816 assertThat(dynamicModifierPress).isEqualTo(0)
2817 assertThat(dynamicModifierMove).isEqualTo(0)
2818 assertThat(dynamicModifierRelease).isEqualTo(0)
2819
2820 assertThat(pointerEvent).isNotNull()
2821 assertThat(eventsThatShouldNotTrigger).isFalse()
2822 }
2823
2824 // MOVE (original modifier only)
2825 dispatchTouchEvent(
2826 ACTION_MOVE,
2827 box1LayoutCoordinates!!,
2828 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2829 )
2830 rule.runOnUiThread {
2831 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2832 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
2833
2834 // Verify Box 1 existing modifier events
2835 assertThat(preexistingModifierPress).isEqualTo(1)
2836 assertThat(preexistingModifierMove).isEqualTo(1)
2837 assertThat(preexistingModifierRelease).isEqualTo(0)
2838
2839 // Verify Box 1 dynamically added modifier events
2840 assertThat(dynamicModifierPress).isEqualTo(0)
2841 assertThat(dynamicModifierMove).isEqualTo(0)
2842 assertThat(dynamicModifierRelease).isEqualTo(0)
2843
2844 assertThat(pointerEvent).isNotNull()
2845 assertThat(eventsThatShouldNotTrigger).isFalse()
2846 }
2847
2848 // UP (original modifier only)
2849 dispatchTouchEvent(
2850 ACTION_UP,
2851 box1LayoutCoordinates!!,
2852 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2853 )
2854 rule.runOnUiThread {
2855 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2856 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
2857
2858 // Verify Box 1 existing modifier events
2859 assertThat(preexistingModifierPress).isEqualTo(1)
2860 assertThat(preexistingModifierMove).isEqualTo(1)
2861 assertThat(preexistingModifierRelease).isEqualTo(1)
2862
2863 // Verify Box 1 dynamically added modifier events
2864 assertThat(dynamicModifierPress).isEqualTo(0)
2865 assertThat(dynamicModifierMove).isEqualTo(0)
2866 assertThat(dynamicModifierRelease).isEqualTo(0)
2867
2868 assertThat(pointerEvent).isNotNull()
2869 assertThat(eventsThatShouldNotTrigger).isFalse()
2870 }
2871
2872 enableDynamicPointerInput = true
2873 rule.waitForFutureFrame(2)
2874
2875 // Important Note: Even though we reset all the pointer input blocks, the initial lambda is
2876 // lazily executed, meaning it won't reset the values until the first event comes in, so
2877 // the previously set values are still the same until an event comes in.
2878 rule.runOnUiThread {
2879 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
2880 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
2881
2882 assertThat(preexistingModifierPress).isEqualTo(1)
2883 assertThat(preexistingModifierMove).isEqualTo(1)
2884 assertThat(preexistingModifierRelease).isEqualTo(1)
2885
2886 assertThat(dynamicModifierPress).isEqualTo(0)
2887 assertThat(dynamicModifierMove).isEqualTo(0)
2888 assertThat(dynamicModifierRelease).isEqualTo(0)
2889 }
2890
2891 // DOWN (original + dynamically added modifiers)
2892 // Now an event comes in, so the lambdas are both executed completely (dynamic one for the
2893 // first time and the existing one for a second time [since it was moved]).
2894 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
2895
2896 rule.runOnUiThread {
2897 // While the original pointer input block is being reused after a new one is added, it
2898 // is reset (since things have changed with the Modifiers), so the entire block is
2899 // executed again to allow devs to reset their gesture detectors for the new Modifier
2900 // chain changes.
2901 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
2902 // The dynamic one has been added, so we execute its thing as well.
2903 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
2904
2905 // Verify Box 1 existing modifier events
2906 assertThat(preexistingModifierPress).isEqualTo(1)
2907 assertThat(preexistingModifierMove).isEqualTo(0)
2908 assertThat(preexistingModifierRelease).isEqualTo(0)
2909
2910 // Verify Box 1 dynamically added modifier events
2911 assertThat(dynamicModifierPress).isEqualTo(1)
2912 assertThat(dynamicModifierMove).isEqualTo(0)
2913 assertThat(dynamicModifierRelease).isEqualTo(0)
2914
2915 assertThat(pointerEvent).isNotNull()
2916 assertThat(eventsThatShouldNotTrigger).isFalse()
2917 }
2918
2919 // MOVE (original + dynamically added modifiers)
2920 dispatchTouchEvent(
2921 ACTION_MOVE,
2922 box1LayoutCoordinates!!,
2923 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2924 )
2925 rule.runOnUiThread {
2926 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
2927 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
2928
2929 // Verify Box 1 existing modifier events
2930 assertThat(preexistingModifierPress).isEqualTo(1)
2931 assertThat(preexistingModifierMove).isEqualTo(1)
2932 assertThat(preexistingModifierRelease).isEqualTo(0)
2933
2934 // Verify Box 1 dynamically added modifier events
2935 assertThat(dynamicModifierPress).isEqualTo(1)
2936 assertThat(dynamicModifierMove).isEqualTo(1)
2937 assertThat(dynamicModifierRelease).isEqualTo(0)
2938
2939 assertThat(pointerEvent).isNotNull()
2940 assertThat(eventsThatShouldNotTrigger).isFalse()
2941 }
2942
2943 // UP (original + dynamically added modifiers)
2944 dispatchTouchEvent(
2945 ACTION_UP,
2946 box1LayoutCoordinates!!,
2947 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
2948 )
2949 rule.runOnUiThread {
2950 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
2951 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
2952
2953 // Verify Box 1 existing modifier events
2954 assertThat(preexistingModifierPress).isEqualTo(1)
2955 assertThat(preexistingModifierMove).isEqualTo(1)
2956 assertThat(preexistingModifierRelease).isEqualTo(1)
2957
2958 // Verify Box 1 dynamically added modifier events
2959 assertThat(dynamicModifierPress).isEqualTo(1)
2960 assertThat(dynamicModifierMove).isEqualTo(1)
2961 assertThat(dynamicModifierRelease).isEqualTo(1)
2962
2963 assertThat(pointerEvent).isNotNull()
2964 assertThat(eventsThatShouldNotTrigger).isFalse()
2965 }
2966 }
2967
2968 /*
2969 * Tests TOUCH events are triggered correctly when dynamically adding a pointer input modifier
2970 * (which uses Unit for its key) ABOVE an existing pointer input modifier.
2971 *
2972 * Specific events:
2973 * 1. UI Element (modifier 1 only): PRESS (touch)
2974 * 2. UI Element (modifier 1 only): MOVE (touch)
2975 * 3. UI Element (modifier 1 only): RELEASE (touch)
2976 * 4. Dynamically add pointer input modifier above existing one (between input event streams)
2977 * 5. UI Element (modifier 1 and 2): PRESS (touch)
2978 * 6. UI Element (modifier 1 and 2): MOVE (touch)
2979 * 7. UI Element (modifier 1 and 2): RELEASE (touch)
2980 */
2981 @Test
2982 fun dynamicInputModifierWithUnitKey_addsAboveExistingModifier_triggersBothModifiers() {
2983 // --> Arrange
2984 var box1LayoutCoordinates: LayoutCoordinates? = null
2985
2986 val setUpFinishedLatch = CountDownLatch(1)
2987
2988 var enableDynamicPointerInput by mutableStateOf(false)
2989
2990 // Events for the lower modifier Box 1
2991 var originalPointerInputScopeExecutionCount by mutableStateOf(0)
2992 var preexistingModifierPress by mutableStateOf(0)
2993 var preexistingModifierMove by mutableStateOf(0)
2994 var preexistingModifierRelease by mutableStateOf(0)
2995
2996 // Events for the dynamic upper modifier Box 1
2997 var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
2998 var dynamicModifierPress by mutableStateOf(0)
2999 var dynamicModifierMove by mutableStateOf(0)
3000 var dynamicModifierRelease by mutableStateOf(0)
3001
3002 // All other events that should never be triggered in this test
3003 var eventsThatShouldNotTrigger by mutableStateOf(false)
3004
3005 var pointerEvent: PointerEvent? by mutableStateOf(null)
3006
3007 // Pointer Input Modifier that is toggled on/off based on passed value.
3008 fun Modifier.dynamicallyToggledPointerInput(
3009 enable: Boolean,
3010 pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
3011 ) =
3012 if (enable) {
3013 pointerInput(Unit) {
3014 ++dynamicPointerInputScopeExecutionCount
3015
3016 // Reset pointer events when lambda is ran the first time
3017 dynamicModifierPress = 0
3018 dynamicModifierMove = 0
3019 dynamicModifierRelease = 0
3020
3021 awaitPointerEventScope {
3022 while (true) {
3023 pointerEventLambda(awaitPointerEvent())
3024 }
3025 }
3026 }
3027 } else this
3028
3029 // Setup UI
3030 rule.runOnUiThread {
3031 container.setContent {
3032 Box(
3033 Modifier.size(200.dp)
3034 .onGloballyPositioned {
3035 box1LayoutCoordinates = it
3036 setUpFinishedLatch.countDown()
3037 }
3038 .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
3039 when (it.type) {
3040 PointerEventType.Press -> {
3041 ++dynamicModifierPress
3042 }
3043 PointerEventType.Move -> {
3044 ++dynamicModifierMove
3045 }
3046 PointerEventType.Release -> {
3047 ++dynamicModifierRelease
3048 }
3049 else -> {
3050 eventsThatShouldNotTrigger = true
3051 }
3052 }
3053 }
3054 .pointerInput(Unit) {
3055 ++originalPointerInputScopeExecutionCount
3056 awaitPointerEventScope {
3057 while (true) {
3058 pointerEvent = awaitPointerEvent()
3059 when (pointerEvent!!.type) {
3060 PointerEventType.Press -> {
3061 ++preexistingModifierPress
3062 }
3063 PointerEventType.Move -> {
3064 ++preexistingModifierMove
3065 }
3066 PointerEventType.Release -> {
3067 ++preexistingModifierRelease
3068 }
3069 else -> {
3070 eventsThatShouldNotTrigger = true
3071 }
3072 }
3073 }
3074 }
3075 }
3076 ) {}
3077 }
3078 }
3079 // Ensure Arrange (setup) step is finished
3080 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
3081
3082 // --> Act + Assert (interwoven)
3083 // DOWN (original modifier only)
3084 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
3085 rule.runOnUiThread {
3086 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3087 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3088
3089 // Verify Box 1 existing modifier events
3090 assertThat(preexistingModifierPress).isEqualTo(1)
3091 assertThat(preexistingModifierMove).isEqualTo(0)
3092 assertThat(preexistingModifierRelease).isEqualTo(0)
3093
3094 // Verify Box 1 dynamically added modifier events
3095 assertThat(dynamicModifierPress).isEqualTo(0)
3096 assertThat(dynamicModifierMove).isEqualTo(0)
3097 assertThat(dynamicModifierRelease).isEqualTo(0)
3098
3099 assertThat(pointerEvent).isNotNull()
3100 assertThat(eventsThatShouldNotTrigger).isFalse()
3101 }
3102
3103 // MOVE (original modifier only)
3104 dispatchTouchEvent(
3105 ACTION_MOVE,
3106 box1LayoutCoordinates!!,
3107 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3108 )
3109 rule.runOnUiThread {
3110 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3111 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3112
3113 // Verify Box 1 existing modifier events
3114 assertThat(preexistingModifierPress).isEqualTo(1)
3115 assertThat(preexistingModifierMove).isEqualTo(1)
3116 assertThat(preexistingModifierRelease).isEqualTo(0)
3117
3118 // Verify Box 1 dynamically added modifier events
3119 assertThat(dynamicModifierPress).isEqualTo(0)
3120 assertThat(dynamicModifierMove).isEqualTo(0)
3121 assertThat(dynamicModifierRelease).isEqualTo(0)
3122
3123 assertThat(pointerEvent).isNotNull()
3124 assertThat(eventsThatShouldNotTrigger).isFalse()
3125 }
3126
3127 // UP (original modifier only)
3128 dispatchTouchEvent(
3129 ACTION_UP,
3130 box1LayoutCoordinates!!,
3131 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3132 )
3133 rule.runOnUiThread {
3134 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3135 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3136
3137 // Verify Box 1 existing modifier events
3138 assertThat(preexistingModifierPress).isEqualTo(1)
3139 assertThat(preexistingModifierMove).isEqualTo(1)
3140 assertThat(preexistingModifierRelease).isEqualTo(1)
3141
3142 // Verify Box 1 dynamically added modifier events
3143 assertThat(dynamicModifierPress).isEqualTo(0)
3144 assertThat(dynamicModifierMove).isEqualTo(0)
3145 assertThat(dynamicModifierRelease).isEqualTo(0)
3146
3147 assertThat(pointerEvent).isNotNull()
3148 assertThat(eventsThatShouldNotTrigger).isFalse()
3149 }
3150
3151 enableDynamicPointerInput = true
3152 rule.waitForFutureFrame(2)
3153
3154 // Important Note: I'm not resetting the variable counters in this test.
3155
3156 // DOWN (original + dynamically added modifiers)
3157 // Now an event comes in, so the lambdas are both executed completely for the first time.
3158 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
3159
3160 rule.runOnUiThread {
3161 // While the original pointer input block is being reused after a new one is added, it
3162 // is reset (since things have changed with the Modifiers), so the entire block is
3163 // executed again to allow devs to reset their gesture detectors for the new Modifier
3164 // chain changes.
3165 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
3166 // The dynamic one has been added, so we execute its lambda as well.
3167 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3168
3169 // Verify Box 1 existing modifier events
3170 assertThat(preexistingModifierPress).isEqualTo(2)
3171 assertThat(preexistingModifierMove).isEqualTo(1)
3172 assertThat(preexistingModifierRelease).isEqualTo(1)
3173
3174 // Verify Box 1 dynamically added modifier events
3175 assertThat(dynamicModifierPress).isEqualTo(1)
3176 assertThat(dynamicModifierMove).isEqualTo(0)
3177 assertThat(dynamicModifierRelease).isEqualTo(0)
3178
3179 assertThat(pointerEvent).isNotNull()
3180 assertThat(eventsThatShouldNotTrigger).isFalse()
3181 }
3182
3183 // MOVE (original + dynamically added modifiers)
3184 dispatchTouchEvent(
3185 ACTION_MOVE,
3186 box1LayoutCoordinates!!,
3187 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3188 )
3189 rule.runOnUiThread {
3190 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
3191 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3192
3193 // Verify Box 1 existing modifier events
3194 assertThat(preexistingModifierPress).isEqualTo(2)
3195 assertThat(preexistingModifierMove).isEqualTo(2)
3196 assertThat(preexistingModifierRelease).isEqualTo(1)
3197
3198 // Verify Box 1 dynamically added modifier events
3199 assertThat(dynamicModifierPress).isEqualTo(1)
3200 assertThat(dynamicModifierMove).isEqualTo(1)
3201 assertThat(dynamicModifierRelease).isEqualTo(0)
3202
3203 assertThat(pointerEvent).isNotNull()
3204 assertThat(eventsThatShouldNotTrigger).isFalse()
3205 }
3206
3207 // UP (original + dynamically added modifiers)
3208 dispatchTouchEvent(
3209 ACTION_UP,
3210 box1LayoutCoordinates!!,
3211 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3212 )
3213 rule.runOnUiThread {
3214 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
3215 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3216
3217 // Verify Box 1 existing modifier events
3218 assertThat(preexistingModifierPress).isEqualTo(2)
3219 assertThat(preexistingModifierMove).isEqualTo(2)
3220 assertThat(preexistingModifierRelease).isEqualTo(2)
3221
3222 // Verify Box 1 dynamically added modifier events
3223 assertThat(dynamicModifierPress).isEqualTo(1)
3224 assertThat(dynamicModifierMove).isEqualTo(1)
3225 assertThat(dynamicModifierRelease).isEqualTo(1)
3226
3227 assertThat(pointerEvent).isNotNull()
3228 assertThat(eventsThatShouldNotTrigger).isFalse()
3229 }
3230 }
3231
3232 /*
3233 * Tests how the input system handles TOUCH events going into a pointer input modifier node
3234 * when its parent's pointer input modifier is dynamically removed DURING an event stream, that
3235 * is, before the event stream ends.
3236 *
3237 * After any pointer input modifier node is removed, any existing event stream is cancelled,
3238 * and any follow up events from that stream are ignored. Any new event streams, will trigger
3239 * the appropriate nodes in the new tree.
3240 *
3241 * They key used is Unit.
3242 *
3243 * Specific events:
3244 * 1. UI Element (parent and child location): PRESS (touch)
3245 * 2. UI Element (parent and child location): MULTIPLE MOVE (touch)
3246 * 3. Dynamically remove parent pointer input modifier (between input event streams)
3247 * 4. System sends generated RELEASE to parent and child (this is not a user sent release)
3248 * -- Events send in old input stream (Note: A new input stream starts with PRESS). ---
3249 * 5. UI Element (child only location [parent gone]): MULTIPLE MOVE (touch) - doesn't trigger
3250 * 6. UI Element (modifier 1 and 2): RELEASE (touch) - doesn't trigger
3251 *
3252 * TODO: If support added for dynamic modifier DURING event streams, modify test
3253 */
3254 @Test
3255 fun pointerInputEvents_removeParentInputModifierDuringStream_noFurtherEventsTriggerForStream() {
3256 // --> Arrange
3257 var parentBoxLayoutCoordinates: LayoutCoordinates? = null
3258 var childBoxLayoutCoordinates: LayoutCoordinates? = null
3259
3260 val setUpFinishedLatch = CountDownLatch(2)
3261
3262 var enableDynamicPointerInput by mutableStateOf(true)
3263
3264 // Events for the Parent Box with dynamic pointer input modifier
3265 var parentBoxDynamicPointerInputScopeExecutionCount by mutableStateOf(0)
3266 var parentBoxDynamicModifierPress by mutableStateOf(0)
3267 var parentBoxDynamicModifierMove by mutableStateOf(0)
3268 var parentBoxDynamicModifierRelease by mutableStateOf(0)
3269
3270 // Events for the child Box
3271 var childBoxPointerInputScopeExecutionCount by mutableStateOf(0)
3272 var childBoxModifierPress by mutableStateOf(0)
3273 var childBoxModifierMove by mutableStateOf(0)
3274 var childBoxModifierRelease by mutableStateOf(0)
3275
3276 // All other events that should never be triggered in this test
3277 var eventsThatShouldNotTrigger by mutableStateOf(false)
3278
3279 var pointerEvent: PointerEvent? by mutableStateOf(null)
3280
3281 // Pointer Input Modifier that is toggled on/off based on passed value.
3282 fun Modifier.dynamicallyToggledPointerInput(
3283 enable: Boolean,
3284 pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
3285 ) =
3286 if (enable) {
3287 pointerInput(Unit) {
3288 ++parentBoxDynamicPointerInputScopeExecutionCount
3289
3290 // Reset pointer events when lambda is ran the first time
3291 parentBoxDynamicModifierPress = 0
3292 parentBoxDynamicModifierMove = 0
3293 parentBoxDynamicModifierRelease = 0
3294
3295 awaitPointerEventScope {
3296 while (true) {
3297 pointerEventLambda(awaitPointerEvent())
3298 }
3299 }
3300 }
3301 } else this
3302
3303 // Setup UI
3304 rule.runOnUiThread {
3305 container.setContent {
3306 // Parent Box
3307 Box(
3308 Modifier.background(Color.Green)
3309 .size(400.dp)
3310 .onGloballyPositioned {
3311 parentBoxLayoutCoordinates = it
3312 setUpFinishedLatch.countDown()
3313 }
3314 .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
3315 when (it.type) {
3316 PointerEventType.Press -> {
3317 ++parentBoxDynamicModifierPress
3318 }
3319 PointerEventType.Move -> {
3320 ++parentBoxDynamicModifierMove
3321 }
3322 PointerEventType.Release -> {
3323 ++parentBoxDynamicModifierRelease
3324 }
3325 else -> {
3326 eventsThatShouldNotTrigger = true
3327 }
3328 }
3329 }
3330 ) {
3331 // Child box
3332 Box(
3333 Modifier.background(Color.Red)
3334 .size(200.dp)
3335 .onGloballyPositioned {
3336 childBoxLayoutCoordinates = it
3337 setUpFinishedLatch.countDown()
3338 }
3339 .pointerInput(Unit) {
3340 ++childBoxPointerInputScopeExecutionCount
3341 awaitPointerEventScope {
3342 while (true) {
3343 pointerEvent = awaitPointerEvent()
3344 when (pointerEvent!!.type) {
3345 PointerEventType.Press -> {
3346 ++childBoxModifierPress
3347 }
3348 PointerEventType.Move -> {
3349 ++childBoxModifierMove
3350 }
3351 PointerEventType.Release -> {
3352 ++childBoxModifierRelease
3353 }
3354 else -> {
3355 eventsThatShouldNotTrigger = true
3356 }
3357 }
3358 }
3359 }
3360 }
3361 ) {}
3362 }
3363 }
3364 }
3365 // Ensure Arrange (setup) step is finished
3366 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
3367
3368 // --> Act + Assert (interwoven)
3369 // DOWN (Starts Event Stream)
3370 dispatchTouchEvent(ACTION_DOWN, parentBoxLayoutCoordinates!!)
3371 rule.runOnUiThread {
3372 assertThat(childBoxPointerInputScopeExecutionCount).isEqualTo(1)
3373 assertThat(parentBoxDynamicPointerInputScopeExecutionCount).isEqualTo(1)
3374
3375 assertThat(parentBoxDynamicModifierPress).isEqualTo(1)
3376 assertThat(parentBoxDynamicModifierMove).isEqualTo(0)
3377 assertThat(parentBoxDynamicModifierRelease).isEqualTo(0)
3378
3379 assertThat(childBoxModifierPress).isEqualTo(1)
3380 assertThat(childBoxModifierMove).isEqualTo(0)
3381 assertThat(childBoxModifierRelease).isEqualTo(0)
3382
3383 assertThat(pointerEvent).isNotNull()
3384 assertThat(eventsThatShouldNotTrigger).isFalse()
3385 }
3386
3387 val moveAmount = 2f
3388 var moveYLocation = 0f
3389 var moveCount = 0
3390
3391 (0..4).forEach { _ ->
3392 moveYLocation += moveAmount
3393 moveCount++
3394
3395 dispatchTouchEvent(ACTION_MOVE, parentBoxLayoutCoordinates!!, Offset(0f, moveYLocation))
3396 rule.runOnUiThread {
3397 assertThat(childBoxPointerInputScopeExecutionCount).isEqualTo(1)
3398 assertThat(parentBoxDynamicPointerInputScopeExecutionCount).isEqualTo(1)
3399
3400 assertThat(parentBoxDynamicModifierPress).isEqualTo(1)
3401 assertThat(parentBoxDynamicModifierMove).isEqualTo(moveCount)
3402 assertThat(parentBoxDynamicModifierRelease).isEqualTo(0)
3403
3404 assertThat(childBoxModifierPress).isEqualTo(1)
3405 assertThat(childBoxModifierMove).isEqualTo(moveCount)
3406 assertThat(childBoxModifierRelease).isEqualTo(0)
3407
3408 assertThat(pointerEvent).isNotNull()
3409 assertThat(eventsThatShouldNotTrigger).isFalse()
3410 }
3411 }
3412
3413 // Remove top pointer modifier node
3414 enableDynamicPointerInput = false
3415 rule.waitForFutureFrame(2)
3416
3417 // A generated Release was sent, but now all events moving forward do not trigger the
3418 // event listeners, since this is still the cancelled event stream.
3419 // (If we wanted to start a new event stream, we'd dispatch an ACTION_DOWN or hover enter).
3420
3421 moveYLocation = 0f
3422 val oldMoveCount = moveCount
3423
3424 (0..4).forEach { _ ->
3425 moveYLocation += moveAmount
3426 moveCount++
3427
3428 dispatchTouchEvent(ACTION_MOVE, childBoxLayoutCoordinates!!, Offset(0f, moveYLocation))
3429 rule.runOnUiThread {
3430 assertThat(childBoxPointerInputScopeExecutionCount).isEqualTo(1)
3431 assertThat(parentBoxDynamicPointerInputScopeExecutionCount).isEqualTo(1)
3432
3433 assertThat(parentBoxDynamicModifierPress).isEqualTo(1)
3434 assertThat(parentBoxDynamicModifierMove).isEqualTo(oldMoveCount)
3435 assertThat(parentBoxDynamicModifierRelease).isEqualTo(1)
3436
3437 assertThat(childBoxModifierPress).isEqualTo(1)
3438 assertThat(childBoxModifierMove).isEqualTo(oldMoveCount)
3439 assertThat(childBoxModifierRelease).isEqualTo(1)
3440
3441 assertThat(pointerEvent).isNotNull()
3442 assertThat(eventsThatShouldNotTrigger).isFalse()
3443 }
3444 }
3445
3446 dispatchTouchEvent(
3447 ACTION_UP,
3448 childBoxLayoutCoordinates!!,
3449 )
3450 rule.runOnUiThread {
3451 assertThat(childBoxPointerInputScopeExecutionCount).isEqualTo(1)
3452 assertThat(parentBoxDynamicPointerInputScopeExecutionCount).isEqualTo(1)
3453
3454 assertThat(parentBoxDynamicModifierPress).isEqualTo(1)
3455 assertThat(parentBoxDynamicModifierMove).isEqualTo(oldMoveCount)
3456 assertThat(parentBoxDynamicModifierRelease).isEqualTo(1)
3457
3458 assertThat(childBoxModifierPress).isEqualTo(1)
3459 assertThat(childBoxModifierMove).isEqualTo(oldMoveCount)
3460 assertThat(childBoxModifierRelease).isEqualTo(1)
3461
3462 assertThat(pointerEvent).isNotNull()
3463 assertThat(eventsThatShouldNotTrigger).isFalse()
3464 }
3465 }
3466
3467 /*
3468 * Tests TOUCH events are triggered correctly when dynamically adding a pointer input
3469 * modifier BELOW an existing pointer input modifier.
3470 *
3471 * Note: The lambda for the existing pointer input modifier is NOT re-executed after the
3472 * dynamic one is added below it (since it doesn't impact it).
3473 *
3474 * Specific events:
3475 * 1. UI Element (modifier 1 only): PRESS (touch)
3476 * 2. UI Element (modifier 1 only): MOVE (touch)
3477 * 3. UI Element (modifier 1 only): RELEASE (touch)
3478 * 4. Dynamically add pointer input modifier below existing one (between input event streams)
3479 * 5. UI Element (modifier 1 and 2): PRESS (touch)
3480 * 6. UI Element (modifier 1 and 2): MOVE (touch)
3481 * 7. UI Element (modifier 1 and 2): RELEASE (touch)
3482 */
3483 @Test
3484 fun dynamicInputModifierWithKey_addsBelowExistingModifier_shouldTriggerInNewModifier() {
3485 // --> Arrange
3486 val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
3487 var box1LayoutCoordinates: LayoutCoordinates? = null
3488
3489 val setUpFinishedLatch = CountDownLatch(1)
3490
3491 var enableDynamicPointerInput by mutableStateOf(false)
3492
3493 // Events for the lower modifier Box 1
3494 var originalPointerInputScopeExecutionCount by mutableStateOf(0)
3495 var preexistingModifierPress by mutableStateOf(0)
3496 var preexistingModifierMove by mutableStateOf(0)
3497 var preexistingModifierRelease by mutableStateOf(0)
3498
3499 // Events for the dynamic upper modifier Box 1
3500 var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
3501 var dynamicModifierPress by mutableStateOf(0)
3502 var dynamicModifierMove by mutableStateOf(0)
3503 var dynamicModifierRelease by mutableStateOf(0)
3504
3505 // All other events that should never be triggered in this test
3506 var eventsThatShouldNotTrigger by mutableStateOf(false)
3507
3508 var pointerEvent: PointerEvent? by mutableStateOf(null)
3509
3510 // Pointer Input Modifier that is toggled on/off based on passed value.
3511 fun Modifier.dynamicallyToggledPointerInput(
3512 enable: Boolean,
3513 pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
3514 ) =
3515 if (enable) {
3516 pointerInput(pointerEventLambda) {
3517 ++dynamicPointerInputScopeExecutionCount
3518
3519 // Reset pointer events when lambda is ran the first time
3520 dynamicModifierPress = 0
3521 dynamicModifierMove = 0
3522 dynamicModifierRelease = 0
3523
3524 awaitPointerEventScope {
3525 while (true) {
3526 pointerEventLambda(awaitPointerEvent())
3527 }
3528 }
3529 }
3530 } else this
3531
3532 // Setup UI
3533 rule.runOnUiThread {
3534 container.setContent {
3535 Box(
3536 Modifier.size(200.dp)
3537 .onGloballyPositioned {
3538 box1LayoutCoordinates = it
3539 setUpFinishedLatch.countDown()
3540 }
3541 .pointerInput(originalPointerInputModifierKey) {
3542 ++originalPointerInputScopeExecutionCount
3543 // Reset pointer events when lambda is ran the first time
3544 preexistingModifierPress = 0
3545 preexistingModifierMove = 0
3546 preexistingModifierRelease = 0
3547
3548 awaitPointerEventScope {
3549 while (true) {
3550 pointerEvent = awaitPointerEvent()
3551 when (pointerEvent!!.type) {
3552 PointerEventType.Press -> {
3553 ++preexistingModifierPress
3554 }
3555 PointerEventType.Move -> {
3556 ++preexistingModifierMove
3557 }
3558 PointerEventType.Release -> {
3559 ++preexistingModifierRelease
3560 }
3561 else -> {
3562 eventsThatShouldNotTrigger = true
3563 }
3564 }
3565 }
3566 }
3567 }
3568 .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
3569 when (it.type) {
3570 PointerEventType.Press -> {
3571 ++dynamicModifierPress
3572 }
3573 PointerEventType.Move -> {
3574 ++dynamicModifierMove
3575 }
3576 PointerEventType.Release -> {
3577 ++dynamicModifierRelease
3578 }
3579 else -> {
3580 eventsThatShouldNotTrigger = true
3581 }
3582 }
3583 }
3584 ) {}
3585 }
3586 }
3587 // Ensure Arrange (setup) step is finished
3588 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
3589
3590 // --> Act + Assert (interwoven)
3591 // DOWN (original modifier only)
3592 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
3593 rule.runOnUiThread {
3594 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3595 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3596
3597 // Verify Box 1 existing modifier events
3598 assertThat(preexistingModifierPress).isEqualTo(1)
3599 assertThat(preexistingModifierMove).isEqualTo(0)
3600 assertThat(preexistingModifierRelease).isEqualTo(0)
3601
3602 // Verify Box 1 dynamically added modifier events
3603 assertThat(dynamicModifierPress).isEqualTo(0)
3604 assertThat(dynamicModifierMove).isEqualTo(0)
3605 assertThat(dynamicModifierRelease).isEqualTo(0)
3606
3607 assertThat(pointerEvent).isNotNull()
3608 assertThat(eventsThatShouldNotTrigger).isFalse()
3609 }
3610
3611 // MOVE (original modifier only)
3612 dispatchTouchEvent(
3613 ACTION_MOVE,
3614 box1LayoutCoordinates!!,
3615 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3616 )
3617 rule.runOnUiThread {
3618 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3619 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3620
3621 // Verify Box 1 existing modifier events
3622 assertThat(preexistingModifierPress).isEqualTo(1)
3623 assertThat(preexistingModifierMove).isEqualTo(1)
3624 assertThat(preexistingModifierRelease).isEqualTo(0)
3625
3626 // Verify Box 1 dynamically added modifier events
3627 assertThat(dynamicModifierPress).isEqualTo(0)
3628 assertThat(dynamicModifierMove).isEqualTo(0)
3629 assertThat(dynamicModifierRelease).isEqualTo(0)
3630
3631 assertThat(pointerEvent).isNotNull()
3632 assertThat(eventsThatShouldNotTrigger).isFalse()
3633 }
3634
3635 // UP (original modifier only)
3636 dispatchTouchEvent(
3637 ACTION_UP,
3638 box1LayoutCoordinates!!,
3639 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3640 )
3641 rule.runOnUiThread {
3642 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3643 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
3644
3645 // Verify Box 1 existing modifier events
3646 assertThat(preexistingModifierPress).isEqualTo(1)
3647 assertThat(preexistingModifierMove).isEqualTo(1)
3648 assertThat(preexistingModifierRelease).isEqualTo(1)
3649
3650 // Verify Box 1 dynamically added modifier events
3651 assertThat(dynamicModifierPress).isEqualTo(0)
3652 assertThat(dynamicModifierMove).isEqualTo(0)
3653 assertThat(dynamicModifierRelease).isEqualTo(0)
3654
3655 assertThat(pointerEvent).isNotNull()
3656 assertThat(eventsThatShouldNotTrigger).isFalse()
3657 }
3658
3659 enableDynamicPointerInput = true
3660 rule.waitForFutureFrame(2)
3661
3662 // DOWN (original + dynamically added modifiers)
3663 dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
3664
3665 rule.runOnUiThread {
3666 // Because the new pointer input modifier is added below the existing one, the existing
3667 // one doesn't change.
3668 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3669 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3670
3671 // Verify Box 1 existing modifier events
3672 assertThat(preexistingModifierPress).isEqualTo(2)
3673 assertThat(preexistingModifierMove).isEqualTo(1)
3674 assertThat(preexistingModifierRelease).isEqualTo(1)
3675
3676 // Verify Box 1 dynamically added modifier events
3677 assertThat(dynamicModifierPress).isEqualTo(1)
3678 assertThat(dynamicModifierMove).isEqualTo(0)
3679 assertThat(dynamicModifierRelease).isEqualTo(0)
3680
3681 assertThat(pointerEvent).isNotNull()
3682 assertThat(eventsThatShouldNotTrigger).isFalse()
3683 }
3684
3685 // MOVE (original + dynamically added modifiers)
3686 dispatchTouchEvent(
3687 ACTION_MOVE,
3688 box1LayoutCoordinates!!,
3689 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3690 )
3691 rule.runOnUiThread {
3692 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3693 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3694
3695 // Verify Box 1 existing modifier events
3696 assertThat(preexistingModifierPress).isEqualTo(2)
3697 assertThat(preexistingModifierMove).isEqualTo(2)
3698 assertThat(preexistingModifierRelease).isEqualTo(1)
3699
3700 // Verify Box 1 dynamically added modifier events
3701 assertThat(dynamicModifierPress).isEqualTo(1)
3702 assertThat(dynamicModifierMove).isEqualTo(1)
3703 assertThat(dynamicModifierRelease).isEqualTo(0)
3704
3705 assertThat(pointerEvent).isNotNull()
3706 assertThat(eventsThatShouldNotTrigger).isFalse()
3707 }
3708
3709 // UP (original + dynamically added modifiers)
3710 dispatchTouchEvent(
3711 ACTION_UP,
3712 box1LayoutCoordinates!!,
3713 Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
3714 )
3715 rule.runOnUiThread {
3716 assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
3717 assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
3718
3719 // Verify Box 1 existing modifier events
3720 assertThat(preexistingModifierPress).isEqualTo(2)
3721 assertThat(preexistingModifierMove).isEqualTo(2)
3722 assertThat(preexistingModifierRelease).isEqualTo(2)
3723
3724 // Verify Box 1 dynamically added modifier events
3725 assertThat(dynamicModifierPress).isEqualTo(1)
3726 assertThat(dynamicModifierMove).isEqualTo(1)
3727 assertThat(dynamicModifierRelease).isEqualTo(1)
3728
3729 assertThat(pointerEvent).isNotNull()
3730 assertThat(eventsThatShouldNotTrigger).isFalse()
3731 }
3732 }
3733
3734 /*
3735 * Tests a full mouse event cycle from a press and release.
3736 *
3737 * Important Note: The pointer id should stay the same throughout all these events (part of the
3738 * test).
3739 *
3740 * Specific MotionEvents:
3741 * 1. UI Element 1: ENTER (hover enter [mouse])
3742 * 2. UI Element 1: EXIT (hover exit [mouse]) - Doesn't trigger Compose PointerEvent
3743 * 3. UI Element 1: PRESS (mouse)
3744 * 4. UI Element 1: ACTION_BUTTON_PRESS (mouse)
3745 * 5. UI Element 1: ACTION_BUTTON_RELEASE (mouse)
3746 * 6. UI Element 1: RELEASE (mouse)
3747 * 7. UI Element 1: ENTER (hover enter [mouse]) - Doesn't trigger Compose PointerEvent
3748 * 8. UI Element 1: EXIT (hover enter [mouse])
3749 *
3750 * Should NOT trigger any additional events (like an extra press or exit)!
3751 */
3752 @Test
3753 fun mouseEventsAndPointerIds_completeMouseEventCycle_pointerIdsShouldMatchAcrossAllEvents() {
3754 // --> Arrange
3755 var box1LayoutCoordinates: LayoutCoordinates? = null
3756
3757 val setUpFinishedLatch = CountDownLatch(1)
3758
3759 // Events for Box
3760 var hoverEventCount = 0
3761 var hoverExitCount = 0
3762 var downCount = 0
3763 // unknownCount covers both button action press and release from Android system for a
3764 // mouse. These events happen between the normal press and release events.
3765 var unknownCount = 0
3766 var upCount = 0
3767
3768 // We want to assert that each updated pointer id matches the original pointer id that
3769 // starts the sequence of MotionEvents.
3770 var originalPointerId = -1L
3771 var box1PointerId = -1L
3772
3773 // All other events that should never be triggered in this test
3774 var eventsThatShouldNotTrigger = false
3775
3776 var pointerEvent: PointerEvent? = null
3777
3778 rule.runOnUiThread {
3779 container.setContent {
3780 Box(
3781 Modifier.size(50.dp)
3782 .onGloballyPositioned {
3783 box1LayoutCoordinates = it
3784 setUpFinishedLatch.countDown()
3785 }
3786 .pointerInput(Unit) {
3787 awaitPointerEventScope {
3788 while (true) {
3789 pointerEvent = awaitPointerEvent()
3790
3791 if (originalPointerId < 0) {
3792 originalPointerId = pointerEvent!!.changes[0].id.value
3793 }
3794
3795 box1PointerId = pointerEvent!!.changes[0].id.value
3796
3797 when (pointerEvent!!.type) {
3798 PointerEventType.Enter -> {
3799 ++hoverEventCount
3800 }
3801 PointerEventType.Press -> {
3802 ++downCount
3803 }
3804 PointerEventType.Release -> {
3805 ++upCount
3806 }
3807 PointerEventType.Exit -> {
3808 ++hoverExitCount
3809 }
3810 PointerEventType.Unknown -> {
3811 ++unknownCount
3812 }
3813 else -> {
3814 eventsThatShouldNotTrigger = true
3815 }
3816 }
3817 }
3818 }
3819 }
3820 ) {}
3821 }
3822 }
3823 // Ensure Arrange (setup) step is finished
3824 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
3825
3826 // --> Act + Assert (interwoven)
3827 dispatchMouseEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
3828 rule.runOnUiThread {
3829 // Verify Box 1 events and pointer id
3830 assertThat(originalPointerId).isEqualTo(box1PointerId)
3831 assertThat(hoverEventCount).isEqualTo(1)
3832 assertThat(hoverExitCount).isEqualTo(0)
3833 assertThat(downCount).isEqualTo(0)
3834 assertThat(unknownCount).isEqualTo(0)
3835 assertThat(upCount).isEqualTo(0)
3836
3837 assertThat(pointerEvent).isNotNull()
3838 assertThat(eventsThatShouldNotTrigger).isFalse()
3839 assertHoverEvent(pointerEvent!!, isEnter = true)
3840 }
3841
3842 pointerEvent = null // Reset before each event
3843
3844 // This will be interpreted as a synthetic event triggered by an ACTION_DOWN because we
3845 // don't wait several frames before triggering the ACTION_DOWN. Thus, no hover exit is
3846 // triggered.
3847 dispatchMouseEvent(ACTION_HOVER_EXIT, box1LayoutCoordinates!!)
3848 dispatchMouseEvent(ACTION_DOWN, box1LayoutCoordinates!!)
3849
3850 rule.runOnUiThread {
3851 assertThat(originalPointerId).isEqualTo(box1PointerId)
3852 assertThat(hoverEventCount).isEqualTo(1)
3853 assertThat(hoverExitCount).isEqualTo(0)
3854 assertThat(downCount).isEqualTo(1)
3855 assertThat(unknownCount).isEqualTo(0)
3856 assertThat(upCount).isEqualTo(0)
3857
3858 assertThat(pointerEvent).isNotNull()
3859 assertThat(eventsThatShouldNotTrigger).isFalse()
3860 }
3861
3862 pointerEvent = null // Reset before each event
3863 dispatchMouseEvent(ACTION_BUTTON_PRESS, box1LayoutCoordinates!!)
3864 rule.runOnUiThread {
3865 // Verify Box 1 events
3866 assertThat(originalPointerId).isEqualTo(box1PointerId)
3867 assertThat(hoverEventCount).isEqualTo(1)
3868 assertThat(hoverExitCount).isEqualTo(0)
3869 assertThat(downCount).isEqualTo(1)
3870 // unknownCount covers both button action press and release from Android system for a
3871 // mouse. These events happen between the normal press and release events.
3872 assertThat(unknownCount).isEqualTo(1)
3873 assertThat(upCount).isEqualTo(0)
3874
3875 assertThat(pointerEvent).isNotNull()
3876 assertThat(eventsThatShouldNotTrigger).isFalse()
3877 }
3878
3879 pointerEvent = null // Reset before each event
3880 dispatchMouseEvent(ACTION_BUTTON_RELEASE, box1LayoutCoordinates!!)
3881 rule.runOnUiThread {
3882 assertThat(originalPointerId).isEqualTo(box1PointerId)
3883 assertThat(hoverEventCount).isEqualTo(1)
3884 assertThat(hoverExitCount).isEqualTo(0)
3885 assertThat(downCount).isEqualTo(1)
3886 // unknownCount covers both button action press and release from Android system for a
3887 // mouse. These events happen between the normal press and release events.
3888 assertThat(unknownCount).isEqualTo(2)
3889 assertThat(upCount).isEqualTo(0)
3890
3891 assertThat(pointerEvent).isNotNull()
3892 assertThat(eventsThatShouldNotTrigger).isFalse()
3893 }
3894
3895 pointerEvent = null // Reset before each event
3896 dispatchMouseEvent(ACTION_UP, box1LayoutCoordinates!!)
3897 // Compose already considered us as ENTERING the UI Element, so we don't need to trigger
3898 // it again. (This is a synthetic hover enter anyway sent from platform with UP.)
3899 dispatchMouseEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
3900
3901 rule.runOnUiThread {
3902 assertThat(originalPointerId).isEqualTo(box1PointerId)
3903 assertThat(hoverEventCount).isEqualTo(1)
3904 assertThat(hoverExitCount).isEqualTo(0)
3905 assertThat(downCount).isEqualTo(1)
3906 assertThat(unknownCount).isEqualTo(2)
3907 assertThat(upCount).isEqualTo(1)
3908
3909 assertThat(pointerEvent).isNotNull()
3910 assertThat(eventsThatShouldNotTrigger).isFalse()
3911 }
3912
3913 pointerEvent = null // Reset before each event
3914 dispatchMouseEvent(ACTION_HOVER_EXIT, box1LayoutCoordinates!!)
3915
3916 // Wait enough time for timeout on hover exit to trigger
3917 rule.waitForFutureFrame(2)
3918
3919 rule.runOnUiThread {
3920 assertThat(originalPointerId).isEqualTo(box1PointerId)
3921 assertThat(hoverEventCount).isEqualTo(1)
3922 assertThat(hoverExitCount).isEqualTo(1)
3923 assertThat(downCount).isEqualTo(1)
3924 assertThat(unknownCount).isEqualTo(2)
3925 assertThat(upCount).isEqualTo(1)
3926
3927 assertThat(pointerEvent).isNotNull()
3928 assertThat(eventsThatShouldNotTrigger).isFalse()
3929 }
3930 }
3931
3932 /*
3933 * Tests an ACTION_HOVER_EXIT MotionEvent is ignored in Compose when it proceeds an
3934 * ACTION_DOWN MotionEvent (in a measure of milliseconds only).
3935 *
3936 * The event order of MotionEvents:
3937 * - Hover enter on box 1
3938 * - Loop 10 times:
3939 * - Hover enter on box 1
3940 * - Down on box 1
3941 * - Up on box 1
3942 */
3943 @Test
3944 fun hoverExitBeforeDownMotionEvent_shortDelayBetweenMotionEvents_shouldNotTriggerHoverExit() {
3945 // --> Arrange
3946 var box1LayoutCoordinates: LayoutCoordinates? = null
3947
3948 val setUpFinishedLatch = CountDownLatch(4)
3949
3950 // Events for Box 1
3951 var enterBox1 = false
3952 var pressBox1 = false
3953
3954 // All other events that should never be triggered in this test
3955 var eventsThatShouldNotTrigger = false
3956
3957 var pointerEvent: PointerEvent? = null
3958
3959 rule.runOnUiThread {
3960 container.setContent {
3961 Column(
3962 Modifier.fillMaxSize().onGloballyPositioned { setUpFinishedLatch.countDown() }
3963 ) {
3964 // Box 1
3965 Box(
3966 Modifier.size(50.dp)
3967 .onGloballyPositioned {
3968 box1LayoutCoordinates = it
3969 setUpFinishedLatch.countDown()
3970 }
3971 .pointerInput(Unit) {
3972 awaitPointerEventScope {
3973 while (true) {
3974 pointerEvent = awaitPointerEvent()
3975
3976 when (pointerEvent!!.type) {
3977 PointerEventType.Enter -> {
3978 enterBox1 = true
3979 }
3980 PointerEventType.Exit -> {
3981 enterBox1 = false
3982 }
3983 PointerEventType.Press -> {
3984 pressBox1 = true
3985 }
3986 PointerEventType.Release -> {
3987 pressBox1 = false
3988 }
3989 else -> {
3990 eventsThatShouldNotTrigger = true
3991 }
3992 }
3993 }
3994 }
3995 }
3996 ) {}
3997
3998 // Box 2
3999 Box(
4000 Modifier.size(50.dp)
4001 .onGloballyPositioned { setUpFinishedLatch.countDown() }
4002 .pointerInput(Unit) {
4003 awaitPointerEventScope {
4004 while (true) {
4005 pointerEvent = awaitPointerEvent()
4006 // Should never do anything with this UI element.
4007 eventsThatShouldNotTrigger = true
4008 }
4009 }
4010 }
4011 ) {}
4012
4013 // Box 3
4014 Box(
4015 Modifier.size(50.dp)
4016 .onGloballyPositioned { setUpFinishedLatch.countDown() }
4017 .pointerInput(Unit) {
4018 awaitPointerEventScope {
4019 while (true) {
4020 pointerEvent = awaitPointerEvent()
4021 // Should never do anything with this UI element.
4022 eventsThatShouldNotTrigger = true
4023 }
4024 }
4025 }
4026 ) {}
4027 }
4028 }
4029 }
4030 // Ensure Arrange (setup) step is finished
4031 assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
4032
4033 // --> Act + Assert (interwoven)
4034 // Hover Enter on Box 1
4035 dispatchMouseEvent(ACTION_HOVER_ENTER, box1LayoutCoordinates!!)
4036 rule.runOnUiThread {
4037 assertThat(enterBox1).isTrue()
4038 assertThat(pointerEvent).isNotNull()
4039 assertThat(eventsThatShouldNotTrigger).isFalse()
4040 assertHoverEvent(pointerEvent!!, isEnter = true)
4041 }
4042
4043 pointerEvent = null // Reset before each event
4044
4045 for (index in 0 until 10) {
4046 // We do not use dispatchMouseEvent() to dispatch the following two events, because the
4047 // actions need to be executed in immediate succession.
4048 rule.runOnUiThread {
4049 val root = box1LayoutCoordinates!!.findRootCoordinates()
4050 val pos = root.localPositionOf(box1LayoutCoordinates!!, Offset.Zero)
4051
4052 // Exit on Box 1 right before action down. This happens normally on devices, so we
4053 // are recreating it here. However, Compose ignores the exit if it is right before
4054 // a down (right before meaning within a couple milliseconds). We verify that it
4055 // did in fact ignore this exit.
4056 val exitMotionEvent =
4057 MotionEvent(
4058 0,
4059 ACTION_HOVER_EXIT,
4060 1,
4061 0,
4062 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_MOUSE }),
4063 arrayOf(PointerCoords(pos.x, pos.y, Offset.Zero.x, Offset.Zero.y))
4064 )
4065
4066 // Press on Box 1
4067 val downMotionEvent =
4068 MotionEvent(
4069 0,
4070 ACTION_DOWN,
4071 1,
4072 0,
4073 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_MOUSE }),
4074 arrayOf(PointerCoords(pos.x, pos.y, Offset.Zero.x, Offset.Zero.y))
4075 )
4076
4077 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
4078
4079 // Execute events
4080 androidComposeView.dispatchHoverEvent(exitMotionEvent)
4081 androidComposeView.dispatchTouchEvent(downMotionEvent)
4082 }
4083
4084 // In Compose, a hover exit MotionEvent is ignored if it is quickly followed
4085 // by a press.
4086 rule.runOnUiThread {
4087 assertThat(enterBox1).isTrue()
4088 assertThat(pressBox1).isTrue()
4089 assertThat(eventsThatShouldNotTrigger).isFalse()
4090 assertThat(pointerEvent).isNotNull()
4091 }
4092
4093 // Release on Box 1
4094 pointerEvent = null // Reset before each event
4095 dispatchTouchEvent(ACTION_UP, box1LayoutCoordinates!!)
4096
4097 rule.runOnUiThread {
4098 assertThat(enterBox1).isTrue()
4099 assertThat(pressBox1).isFalse()
4100 assertThat(eventsThatShouldNotTrigger).isFalse()
4101 assertThat(pointerEvent).isNotNull()
4102 }
4103 }
4104
4105 rule.runOnUiThread { assertThat(eventsThatShouldNotTrigger).isFalse() }
4106 }
4107
4108 @Test
4109 fun dispatchHoverMove() {
4110 var layoutCoordinates: LayoutCoordinates? = null
4111 var layoutCoordinates2: LayoutCoordinates? = null
4112 val latch = CountDownLatch(1)
4113 val eventLatch = CountDownLatch(1)
4114 var anyOtherEvent = false
4115
4116 var move1 = false
4117 var move2 = false
4118 var move3 = false
4119
4120 var enter: PointerEvent? = null
4121 var move: PointerEvent? = null
4122 var exit: PointerEvent? = null
4123
4124 rule.runOnUiThread {
4125 container.setContent {
4126 Box(
4127 Modifier.fillMaxSize()
4128 .onGloballyPositioned {
4129 layoutCoordinates = it
4130 latch.countDown()
4131 }
4132 .pointerInput(Unit) {
4133 awaitPointerEventScope {
4134 awaitPointerEvent() // enter
4135 assertHoverEvent(awaitPointerEvent()) // move
4136 move1 = true
4137 assertHoverEvent(awaitPointerEvent()) // move
4138 move2 = true
4139 assertHoverEvent(awaitPointerEvent()) // move
4140 move3 = true
4141 awaitPointerEvent() // exit
4142 eventLatch.countDown()
4143 }
4144 }
4145 ) {
4146 Box(
4147 Modifier.align(Alignment.Center)
4148 .size(50.dp)
4149 .onGloballyPositioned { layoutCoordinates2 = it }
4150 .pointerInput(Unit) {
4151 awaitPointerEventScope {
4152 enter = awaitPointerEvent()
4153 move = awaitPointerEvent()
4154 exit = awaitPointerEvent()
4155 awaitPointerEvent()
4156 anyOtherEvent = true
4157 }
4158 }
4159 )
4160 }
4161 }
4162 }
4163 assertTrue(latch.await(1, TimeUnit.SECONDS))
4164 // Enter outer Box
4165 dispatchMouseEvent(ACTION_HOVER_ENTER, layoutCoordinates!!)
4166
4167 // Move to inner Box
4168 dispatchMouseEvent(ACTION_HOVER_MOVE, layoutCoordinates2!!)
4169 rule.runOnUiThread {
4170 assertThat(move1).isTrue()
4171 assertThat(enter).isNotNull()
4172 assertHoverEvent(enter!!, isEnter = true)
4173 }
4174
4175 // Move within inner Box
4176 dispatchMouseEvent(ACTION_HOVER_MOVE, layoutCoordinates2!!, Offset(1f, 1f))
4177 rule.runOnUiThread {
4178 assertThat(move2).isTrue()
4179 assertThat(move).isNotNull()
4180 assertHoverEvent(move!!)
4181 }
4182
4183 // Move to outer Box
4184 dispatchMouseEvent(ACTION_HOVER_MOVE, layoutCoordinates!!)
4185 rule.runOnUiThread {
4186 assertThat(move3).isTrue()
4187 assertThat(exit).isNotNull()
4188 assertHoverEvent(exit!!, isExit = true)
4189 }
4190
4191 // Leave outer Box
4192 dispatchMouseEvent(ACTION_HOVER_EXIT, layoutCoordinates!!)
4193
4194 rule.runOnUiThread { assertThat(anyOtherEvent).isFalse() }
4195 assertTrue(eventLatch.await(1, TimeUnit.SECONDS))
4196 }
4197
4198 @Test
4199 fun dispatchScroll() {
4200 var layoutCoordinates: LayoutCoordinates? = null
4201 val latch = CountDownLatch(1)
4202 val events = mutableListOf<PointerEvent>()
4203 val scrollDelta = Offset(0.35f, 0.65f)
4204 rule.runOnUiThread {
4205 container.setContent {
4206 Box(
4207 Modifier.fillMaxSize()
4208 .onGloballyPositioned {
4209 layoutCoordinates = it
4210 latch.countDown()
4211 }
4212 .pointerInput(Unit) {
4213 awaitPointerEventScope {
4214 while (true) {
4215 val event = awaitPointerEvent()
4216 event.changes[0].consume()
4217 events += event
4218 }
4219 }
4220 }
4221 )
4222 }
4223 }
4224 assertTrue(latch.await(1, TimeUnit.SECONDS))
4225 dispatchMouseEvent(ACTION_SCROLL, layoutCoordinates!!, scrollDelta = scrollDelta)
4226 rule.runOnUiThread {
4227 assertThat(events).hasSize(2) // synthetic enter and scroll
4228 assertHoverEvent(events[0], isEnter = true)
4229 assertScrollEvent(events[1], scrollExpected = scrollDelta)
4230 }
4231 }
4232
4233 @Test
4234 fun dispatchScroll_whenButtonPressed() {
4235 var layoutCoordinates: LayoutCoordinates? = null
4236 val latch = CountDownLatch(1)
4237 val events = mutableListOf<PointerEvent>()
4238 val scrollDelta = Offset(0.35f, 0.65f)
4239 rule.runOnUiThread {
4240 container.setContent {
4241 Box(
4242 Modifier.fillMaxSize()
4243 .onGloballyPositioned {
4244 layoutCoordinates = it
4245 latch.countDown()
4246 }
4247 .pointerInput(Unit) {
4248 awaitPointerEventScope {
4249 while (true) {
4250 val event = awaitPointerEvent()
4251 event.changes[0].consume()
4252 events += event
4253 }
4254 }
4255 }
4256 )
4257 }
4258 }
4259 assertTrue(latch.await(1, TimeUnit.SECONDS))
4260 // press the button first before scroll
4261 dispatchMouseEvent(ACTION_DOWN, layoutCoordinates!!)
4262 dispatchMouseEvent(ACTION_SCROLL, layoutCoordinates!!, scrollDelta = scrollDelta)
4263 rule.runOnUiThread {
4264 assertThat(events).hasSize(3) // synthetic enter, button down, scroll
4265 assertHoverEvent(events[0], isEnter = true)
4266 assert(events[1].changes.fastAll { it.changedToDownIgnoreConsumed() })
4267 assertScrollEvent(events[2], scrollExpected = scrollDelta)
4268 }
4269 }
4270
4271 @Test
4272 fun dispatchScroll_batch() {
4273 var layoutCoordinates: LayoutCoordinates? = null
4274 val latch = CountDownLatch(1)
4275 val events = mutableListOf<PointerEvent>()
4276 val scrollDelta1 = Offset(0.32f, -0.75f)
4277 val scrollDelta2 = Offset(0.14f, 0.35f)
4278 val scrollDelta3 = Offset(-0.30f, -0.12f)
4279 val scrollDelta4 = Offset(-0.05f, 0.68f)
4280 rule.runOnUiThread {
4281 container.setContent {
4282 Box(
4283 Modifier.fillMaxSize()
4284 .onGloballyPositioned {
4285 layoutCoordinates = it
4286 latch.countDown()
4287 }
4288 .pointerInput(Unit) {
4289 awaitPointerEventScope {
4290 while (true) {
4291 val event = awaitPointerEvent()
4292 event.changes[0].consume()
4293 events += event
4294 }
4295 }
4296 }
4297 )
4298 }
4299 }
4300 assertTrue(latch.await(1, TimeUnit.SECONDS))
4301 listOf(scrollDelta1, scrollDelta2, scrollDelta3, scrollDelta4).fastForEach {
4302 dispatchMouseEvent(ACTION_SCROLL, layoutCoordinates!!, scrollDelta = it)
4303 }
4304 rule.runOnUiThread {
4305 assertThat(events).hasSize(5) // 4 + synthetic enter
4306 assertHoverEvent(events[0], isEnter = true)
4307 assertScrollEvent(events[1], scrollExpected = scrollDelta1)
4308 assertScrollEvent(events[2], scrollExpected = scrollDelta2)
4309 assertScrollEvent(events[3], scrollExpected = scrollDelta3)
4310 assertScrollEvent(events[4], scrollExpected = scrollDelta4)
4311 }
4312 }
4313
4314 @Test
4315 fun mouseScroll_ignoredAsDownEvent() {
4316 var layoutCoordinates: LayoutCoordinates? = null
4317 val latch = CountDownLatch(1)
4318 val events = mutableListOf<PointerEvent>()
4319 val scrollDelta = Offset(0.35f, 0.65f)
4320 rule.runOnUiThread {
4321 container.setContent {
4322 Box(
4323 Modifier.fillMaxSize()
4324 .onGloballyPositioned {
4325 layoutCoordinates = it
4326 latch.countDown()
4327 }
4328 .pointerInput(Unit) {
4329 awaitPointerEventScope {
4330 while (true) {
4331 val event = awaitPointerEvent()
4332 event.changes[0].consume()
4333 events += event
4334 }
4335 }
4336 }
4337 )
4338 }
4339 }
4340 assertTrue(latch.await(1, TimeUnit.SECONDS))
4341 dispatchMouseEvent(ACTION_SCROLL, layoutCoordinates!!, scrollDelta = scrollDelta)
4342 rule.runOnUiThread {
4343 assertThat(events).hasSize(2) // hover enter + scroll
4344 assertThat(events[1].changes).isNotEmpty()
4345 assertThat(events[1].changes[0].changedToDown()).isFalse()
4346 }
4347 }
4348
4349 @Test
4350 fun mousePress_ignoresHoverExitOnPress() {
4351 lateinit var layoutCoordinates: LayoutCoordinates
4352 val latch = CountDownLatch(1)
4353 val events = mutableListOf<PointerEvent>()
4354 rule.runOnUiThread {
4355 container.setContent {
4356 Box(
4357 Modifier.fillMaxSize()
4358 .onGloballyPositioned {
4359 layoutCoordinates = it
4360 latch.countDown()
4361 }
4362 .pointerInput(Unit) {
4363 awaitPointerEventScope {
4364 while (true) {
4365 val event = awaitPointerEvent()
4366 event.changes[0].consume()
4367 events += event
4368 }
4369 }
4370 }
4371 )
4372 }
4373 }
4374 assertTrue(latch.await(1, TimeUnit.SECONDS))
4375
4376 rule.runOnUiThread {
4377 val root = layoutCoordinates.findRootCoordinates()
4378 val pos = root.localPositionOf(layoutCoordinates, Offset.Zero)
4379 val pointerCoords = PointerCoords(pos.x, pos.y)
4380 val pointerProperties = PointerProperties(0).apply { toolType = TOOL_TYPE_MOUSE }
4381 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
4382
4383 val hoverExitEvent =
4384 MotionEvent(
4385 eventTime = 0,
4386 action = ACTION_HOVER_EXIT,
4387 numPointers = 1,
4388 actionIndex = 0,
4389 pointerProperties = arrayOf(pointerProperties),
4390 pointerCoords = arrayOf(pointerCoords),
4391 buttonState = 0,
4392 )
4393 androidComposeView.dispatchHoverEvent(hoverExitEvent)
4394
4395 val downEvent =
4396 MotionEvent(
4397 eventTime = 0,
4398 action = ACTION_DOWN,
4399 numPointers = 1,
4400 actionIndex = 0,
4401 pointerProperties = arrayOf(pointerProperties),
4402 pointerCoords = arrayOf(pointerCoords),
4403 )
4404 androidComposeView.dispatchTouchEvent(downEvent)
4405 }
4406
4407 rule.runOnUiThread {
4408 assertThat(events).hasSize(1)
4409 assertThat(events.single().type).isEqualTo(PointerEventType.Press)
4410 }
4411 }
4412
4413 @Test
4414 fun hoverEnterPressExitEnterExitRelease() {
4415 var outerCoordinates: LayoutCoordinates? = null
4416 var innerCoordinates: LayoutCoordinates? = null
4417 val latch = CountDownLatch(1)
4418 val eventLog = mutableListOf<PointerEvent>()
4419 rule.runOnUiThread {
4420 container.setContent {
4421 Box(
4422 Modifier.fillMaxSize().onGloballyPositioned {
4423 outerCoordinates = it
4424 latch.countDown()
4425 }
4426 ) {
4427 Box(
4428 Modifier.align(Alignment.Center)
4429 .size(50.dp)
4430 .pointerInput(Unit) {
4431 awaitPointerEventScope {
4432 while (true) {
4433 val event = awaitPointerEvent()
4434 event.changes[0].consume()
4435 eventLog += event
4436 }
4437 }
4438 }
4439 .onGloballyPositioned { innerCoordinates = it }
4440 )
4441 }
4442 }
4443 }
4444 assertTrue(latch.await(1, TimeUnit.SECONDS))
4445 dispatchMouseEvent(ACTION_HOVER_ENTER, outerCoordinates!!)
4446 dispatchMouseEvent(ACTION_HOVER_MOVE, innerCoordinates!!)
4447 dispatchMouseEvent(ACTION_DOWN, innerCoordinates!!)
4448 dispatchMouseEvent(ACTION_MOVE, outerCoordinates!!)
4449 dispatchMouseEvent(ACTION_MOVE, innerCoordinates!!)
4450 dispatchMouseEvent(ACTION_MOVE, outerCoordinates!!)
4451 dispatchMouseEvent(ACTION_UP, outerCoordinates!!)
4452 rule.runOnUiThread {
4453 assertThat(eventLog).hasSize(6)
4454 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4455 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4456 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Exit)
4457 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Enter)
4458 assertThat(eventLog[4].type).isEqualTo(PointerEventType.Exit)
4459 assertThat(eventLog[5].type).isEqualTo(PointerEventType.Release)
4460 }
4461 }
4462
4463 @Test
4464 fun hoverPressEnterRelease() {
4465 var missCoordinates: LayoutCoordinates? = null
4466 var hitCoordinates: LayoutCoordinates? = null
4467 val latch = CountDownLatch(1)
4468 val eventLog = mutableListOf<PointerEvent>()
4469 rule.runOnUiThread {
4470 container.setContent {
4471 Box(Modifier.fillMaxSize()) {
4472 Box(
4473 Modifier.align(AbsoluteAlignment.TopLeft)
4474 .size(50.dp)
4475 .pointerInput(Unit) {
4476 awaitPointerEventScope {
4477 while (true) {
4478 awaitPointerEvent()
4479 }
4480 }
4481 }
4482 .onGloballyPositioned {
4483 missCoordinates = it
4484 latch.countDown()
4485 }
4486 )
4487 Box(
4488 Modifier.align(AbsoluteAlignment.BottomRight)
4489 .size(50.dp)
4490 .pointerInput(Unit) {
4491 awaitPointerEventScope {
4492 while (true) {
4493 val event = awaitPointerEvent()
4494 event.changes[0].consume()
4495 eventLog += event
4496 }
4497 }
4498 }
4499 .onGloballyPositioned { hitCoordinates = it }
4500 )
4501 }
4502 }
4503 }
4504 assertTrue(latch.await(1, TimeUnit.SECONDS))
4505 dispatchMouseEvent(ACTION_HOVER_ENTER, missCoordinates!!)
4506 dispatchMouseEvent(ACTION_HOVER_EXIT, missCoordinates!!)
4507 dispatchMouseEvent(ACTION_DOWN, missCoordinates!!)
4508 dispatchMouseEvent(ACTION_MOVE, hitCoordinates!!)
4509 dispatchMouseEvent(ACTION_UP, hitCoordinates!!)
4510 dispatchMouseEvent(ACTION_HOVER_ENTER, hitCoordinates!!)
4511 rule.runOnUiThread {
4512 assertThat(eventLog).hasSize(1)
4513 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4514 }
4515 }
4516
4517 @Test
4518 fun pressInsideExitWindow() {
4519 var innerCoordinates: LayoutCoordinates? = null
4520 val latch = CountDownLatch(1)
4521 val eventLog = mutableListOf<PointerEvent>()
4522 rule.runOnUiThread {
4523 container.setContent {
4524 Box(Modifier.fillMaxSize()) {
4525 Box(
4526 Modifier.align(Alignment.BottomCenter)
4527 .size(50.dp)
4528 .graphicsLayer { translationY = 25.dp.roundToPx().toFloat() }
4529 .pointerInput(Unit) {
4530 awaitPointerEventScope {
4531 while (true) {
4532 val event = awaitPointerEvent()
4533 event.changes[0].consume()
4534 eventLog += event
4535 }
4536 }
4537 }
4538 .onGloballyPositioned {
4539 innerCoordinates = it
4540 latch.countDown()
4541 }
4542 )
4543 }
4544 }
4545 }
4546 assertTrue(latch.await(1, TimeUnit.SECONDS))
4547 val coords = innerCoordinates!!
4548 dispatchMouseEvent(ACTION_HOVER_ENTER, coords)
4549 dispatchMouseEvent(ACTION_DOWN, coords)
4550 dispatchMouseEvent(ACTION_MOVE, coords, Offset(0f, coords.size.height / 2 - 1f))
4551 dispatchMouseEvent(ACTION_MOVE, coords, Offset(0f, coords.size.height - 1f))
4552 dispatchMouseEvent(ACTION_UP, coords, Offset(0f, coords.size.height - 1f))
4553 rule.runOnUiThread {
4554 assertThat(eventLog).hasSize(5)
4555 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4556 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4557 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Move)
4558 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Exit)
4559 assertThat(eventLog[4].type).isEqualTo(PointerEventType.Release)
4560 }
4561 }
4562
4563 @Test
4564 fun pressInsideClippedContent() {
4565 var innerCoordinates: LayoutCoordinates? = null
4566 val latch = CountDownLatch(1)
4567 val eventLog = mutableListOf<PointerEvent>()
4568 rule.runOnUiThread {
4569 container.setContent {
4570 Box(Modifier.fillMaxSize()) {
4571 Box(Modifier.align(Alignment.TopCenter).requiredSize(50.dp).clipToBounds()) {
4572 Box(
4573 Modifier.requiredSize(50.dp)
4574 .graphicsLayer { translationY = 25.dp.roundToPx().toFloat() }
4575 .pointerInput(Unit) {
4576 awaitPointerEventScope {
4577 while (true) {
4578 val event = awaitPointerEvent()
4579 event.changes[0].consume()
4580 eventLog += event
4581 }
4582 }
4583 }
4584 .onGloballyPositioned {
4585 innerCoordinates = it
4586 latch.countDown()
4587 }
4588 )
4589 }
4590 }
4591 }
4592 }
4593 assertTrue(latch.await(1, TimeUnit.SECONDS))
4594 val coords = innerCoordinates!!
4595 dispatchMouseEvent(ACTION_HOVER_ENTER, coords)
4596 dispatchMouseEvent(ACTION_DOWN, coords)
4597 dispatchMouseEvent(ACTION_MOVE, coords, Offset(0f, coords.size.height - 1f))
4598 dispatchMouseEvent(ACTION_UP, coords, Offset(0f, coords.size.height - 1f))
4599 dispatchMouseEvent(ACTION_HOVER_ENTER, coords, Offset(0f, coords.size.height - 1f))
4600 rule.runOnUiThread {
4601 assertThat(eventLog).hasSize(5)
4602 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4603 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4604 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Move)
4605 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Release)
4606 assertThat(eventLog[4].type).isEqualTo(PointerEventType.Exit)
4607 }
4608 }
4609
4610 private fun setSimpleLayout(eventLog: MutableList<PointerEvent>): LayoutCoordinates {
4611 var innerCoordinates: LayoutCoordinates? = null
4612 val latch = CountDownLatch(1)
4613 rule.runOnUiThread {
4614 container.setContent {
4615 Box(
4616 Modifier.fillMaxSize()
4617 .pointerInput(Unit) {
4618 awaitPointerEventScope {
4619 while (true) {
4620 val event = awaitPointerEvent()
4621 event.changes[0].consume()
4622 eventLog += event
4623 }
4624 }
4625 }
4626 .onGloballyPositioned {
4627 innerCoordinates = it
4628 latch.countDown()
4629 }
4630 )
4631 }
4632 }
4633 assertTrue(latch.await(1, TimeUnit.SECONDS))
4634 return innerCoordinates!!
4635 }
4636
4637 @Test
4638 fun cancelOnDeviceChange() {
4639 // When a pointer has had a surprise removal, a "cancel" event should be sent if it was
4640 // pressed.
4641 val eventLog = mutableListOf<PointerEvent>()
4642 val coords = setSimpleLayout(eventLog)
4643
4644 dispatchMouseEvent(ACTION_HOVER_ENTER, coords)
4645 dispatchMouseEvent(ACTION_DOWN, coords)
4646 dispatchMouseEvent(ACTION_MOVE, coords, Offset(0f, 1f))
4647
4648 val motionEvent =
4649 MotionEvent(
4650 5,
4651 ACTION_DOWN,
4652 1,
4653 0,
4654 arrayOf(PointerProperties(10).also { it.toolType = TOOL_TYPE_FINGER }),
4655 arrayOf(PointerCoords(1f, 1f))
4656 )
4657
4658 container.dispatchTouchEvent(motionEvent)
4659 rule.runOnUiThread {
4660 assertThat(eventLog).hasSize(5)
4661 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4662 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4663 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Move)
4664 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Release)
4665 assertThat(eventLog[4].type).isEqualTo(PointerEventType.Press)
4666 }
4667 }
4668
4669 @Test
4670 fun testSyntheticEventPosition() {
4671 val eventLog = mutableListOf<PointerEvent>()
4672 val coords = setSimpleLayout(eventLog)
4673 dispatchMouseEvent(ACTION_DOWN, coords)
4674
4675 rule.runOnUiThread {
4676 assertThat(eventLog).hasSize(2)
4677 assertThat(eventLog[0].changes[0].position).isEqualTo(eventLog[1].changes[0].position)
4678 }
4679 }
4680
4681 @Test
4682 fun testStylusHoverExitDueToPress() {
4683 val eventLog = mutableListOf<PointerEvent>()
4684 val coords = setSimpleLayout(eventLog)
4685
4686 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4687 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_EXIT, ACTION_DOWN)
4688
4689 rule.runOnUiThread {
4690 assertThat(eventLog).hasSize(2)
4691 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4692 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4693 }
4694 }
4695
4696 @Test
4697 fun testStylusHoverExitNoFollowingEvent() {
4698 val eventLog = mutableListOf<PointerEvent>()
4699 val coords = setSimpleLayout(eventLog)
4700
4701 // Exit followed by nothing should just send the exit
4702 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4703 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_EXIT)
4704
4705 // Hover exit events in Compose are always delayed two frames to ensure Compose does not
4706 // trigger them if they are followed by a press in the next frame. This accounts for that.
4707 rule.waitForFutureFrame(2)
4708
4709 rule.runOnUiThread {
4710 assertThat(eventLog).hasSize(2)
4711 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4712 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Exit)
4713 }
4714 }
4715
4716 @Test
4717 fun testStylusHoverExitWithFollowingHoverEvent() {
4718 val eventLog = mutableListOf<PointerEvent>()
4719 val coords = setSimpleLayout(eventLog)
4720
4721 // Exit immediately followed by enter with the same device should send both
4722 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4723 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_EXIT, ACTION_HOVER_ENTER)
4724
4725 rule.runOnUiThread {
4726 assertThat(eventLog).hasSize(3)
4727 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4728 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Exit)
4729 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Enter)
4730 }
4731 }
4732
4733 @Test
4734 fun testStylusHoverExitWithFollowingTouchEvent() {
4735 val eventLog = mutableListOf<PointerEvent>()
4736 val coords = setSimpleLayout(eventLog)
4737
4738 // Exit followed by cancel should send both
4739 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4740 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_EXIT, ACTION_CANCEL)
4741
4742 rule.runOnUiThread {
4743 assertThat(eventLog).hasSize(2)
4744 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4745 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Exit)
4746 }
4747 }
4748
4749 @Test
4750 fun testStylusHoverExitWithFollowingDownOnDifferentDevice() {
4751 val eventLog = mutableListOf<PointerEvent>()
4752 val coords = setSimpleLayout(eventLog)
4753
4754 // Exit followed by a different device should send the exit
4755 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4756 rule.runOnUiThread {
4757 val root = coords.findRootCoordinates()
4758 val pos = root.localPositionOf(coords, Offset.Zero)
4759 val androidComposeView = findAndroidComposeView(container) as AndroidComposeView
4760 val exit =
4761 MotionEvent(
4762 0,
4763 ACTION_HOVER_EXIT,
4764 1,
4765 0,
4766 arrayOf(
4767 PointerProperties(0).also { it.toolType = MotionEvent.TOOL_TYPE_STYLUS }
4768 ),
4769 arrayOf(PointerCoords(pos.x, pos.y))
4770 )
4771
4772 androidComposeView.dispatchHoverEvent(exit)
4773
4774 val down =
4775 MotionEvent(
4776 0,
4777 ACTION_DOWN,
4778 1,
4779 0,
4780 arrayOf(PointerProperties(0).also { it.toolType = TOOL_TYPE_FINGER }),
4781 arrayOf(PointerCoords(pos.x, pos.y))
4782 )
4783 androidComposeView.dispatchTouchEvent(down)
4784 }
4785
4786 rule.runOnUiThread {
4787 assertThat(eventLog).hasSize(3)
4788 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4789 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Exit)
4790 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Press)
4791 }
4792 }
4793
4794 @Test
4795 fun syntheticEventSentAfterUp() {
4796 val eventLog = mutableListOf<PointerEvent>()
4797 val coords = setSimpleLayout(eventLog)
4798
4799 dispatchMouseEvent(ACTION_HOVER_ENTER, coords)
4800 dispatchMouseEvent(ACTION_DOWN, coords)
4801 dispatchMouseEvent(ACTION_UP, coords)
4802 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_ENTER)
4803
4804 rule.runOnUiThread {
4805 assertThat(eventLog).hasSize(5)
4806 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
4807 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
4808 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Release)
4809 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Exit)
4810 assertThat(eventLog[4].type).isEqualTo(PointerEventType.Enter)
4811 }
4812 }
4813
4814 @Test
4815 fun clippedHasNoInputIfLargeEnough() {
4816 val eventLog = mutableListOf<PointerEventType>()
4817 var innerCoordinates: LayoutCoordinates? = null
4818 val latch = CountDownLatch(1)
4819 rule.runOnUiThread {
4820 container.setContent {
4821 Column(Modifier.fillMaxSize()) {
4822 Box(
4823 Modifier.size(50.dp).pointerInput(Unit) {
4824 awaitPointerEventScope {
4825 while (true) {
4826 awaitPointerEvent()
4827 }
4828 }
4829 }
4830 )
4831 Box(Modifier.size(50.dp).clipToBounds()) {
4832 Box(
4833 Modifier.size(50.dp)
4834 .graphicsLayer { translationY = -25.dp.roundToPx().toFloat() }
4835 .pointerInput(Unit) {
4836 awaitPointerEventScope {
4837 while (true) {
4838 val event = awaitPointerEvent()
4839 event.changes[0].consume()
4840 eventLog += event.type
4841 }
4842 }
4843 }
4844 .onGloballyPositioned {
4845 innerCoordinates = it
4846 latch.countDown()
4847 }
4848 )
4849 }
4850 }
4851 }
4852 }
4853 assertTrue(latch.await(1, TimeUnit.SECONDS))
4854
4855 val coords = innerCoordinates!!
4856
4857 // Hit the top Box
4858 dispatchMouseEvent(ACTION_HOVER_ENTER, coords, Offset(0f, -1f))
4859
4860 // Hit the bottom box, but clipped
4861 dispatchMouseEvent(ACTION_HOVER_MOVE, coords)
4862 dispatchMouseEvent(
4863 ACTION_HOVER_MOVE,
4864 coords,
4865 Offset(0f, (coords.size.height / 2 - 1).toFloat())
4866 )
4867
4868 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
4869
4870 // Now hit the box in the unclipped region
4871 dispatchMouseEvent(
4872 ACTION_HOVER_MOVE,
4873 coords,
4874 Offset(0f, (coords.size.height / 2 + 1).toFloat())
4875 )
4876
4877 // Now hit the bottom of the clipped region
4878 dispatchMouseEvent(
4879 ACTION_HOVER_MOVE,
4880 coords,
4881 Offset(0f, (coords.size.height - 1).toFloat())
4882 )
4883
4884 // Now leave
4885 dispatchMouseEvent(ACTION_HOVER_MOVE, coords, Offset(0f, coords.size.height.toFloat() + 1f))
4886
4887 rule.runOnUiThread {
4888 assertThat(eventLog)
4889 .containsExactly(
4890 PointerEventType.Enter,
4891 PointerEventType.Move,
4892 PointerEventType.Exit
4893 )
4894 }
4895 }
4896
4897 @Test
4898 fun unclippedTakesPrecedenceWithMinimumTouchTarget() {
4899 val eventLog = mutableListOf<PointerEventType>()
4900 var innerCoordinates: LayoutCoordinates? = null
4901 val latch = CountDownLatch(1)
4902 rule.runOnUiThread {
4903 container.setContent {
4904 Column(Modifier.fillMaxSize()) {
4905 Box(
4906 Modifier.size(50.dp).pointerInput(Unit) {
4907 awaitPointerEventScope {
4908 while (true) {
4909 awaitPointerEvent()
4910 }
4911 }
4912 }
4913 )
4914 Box(Modifier.size(20.dp).clipToBounds()) {
4915 Box(
4916 Modifier.size(20.dp)
4917 .graphicsLayer { translationY = -10.dp.roundToPx().toFloat() }
4918 .pointerInput(Unit) {
4919 awaitPointerEventScope {
4920 while (true) {
4921 val event = awaitPointerEvent()
4922 event.changes[0].consume()
4923 eventLog += event.type
4924 }
4925 }
4926 }
4927 .onGloballyPositioned {
4928 innerCoordinates = it
4929 latch.countDown()
4930 }
4931 )
4932 }
4933 }
4934 }
4935 }
4936 assertTrue(latch.await(1, TimeUnit.SECONDS))
4937
4938 val coords = innerCoordinates!!
4939
4940 // Hit the top Box, but in the minimum touch target area of the bottom Box
4941 dispatchTouchEvent(ACTION_DOWN, coords, Offset(0f, -1f))
4942 dispatchTouchEvent(ACTION_UP, coords, Offset(0f, -1f))
4943
4944 // Hit the top Box in the clipped region of the bottom Box
4945 dispatchMouseEvent(ACTION_DOWN, coords)
4946 dispatchMouseEvent(ACTION_UP, coords)
4947
4948 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
4949
4950 // Hit the bottom box in the unclipped region
4951 val topOfUnclipped = Offset(0f, (coords.size.height / 2 + 1).toFloat())
4952 dispatchMouseEvent(ACTION_DOWN, coords, topOfUnclipped)
4953 dispatchMouseEvent(ACTION_UP, coords, topOfUnclipped)
4954
4955 // Continue to the bottom of the bottom Box
4956 val bottomOfBox = Offset(0f, (coords.size.height - 1).toFloat())
4957 dispatchMouseEvent(ACTION_DOWN, coords, bottomOfBox)
4958 dispatchMouseEvent(ACTION_UP, coords, bottomOfBox)
4959
4960 // Now exit the bottom box
4961 val justBelow = Offset(0f, (coords.size.height + 1).toFloat())
4962 dispatchMouseEvent(ACTION_DOWN, coords, justBelow)
4963 dispatchMouseEvent(ACTION_UP, coords, justBelow)
4964
4965 rule.runOnUiThread {
4966 assertThat(eventLog)
4967 .containsExactly(
4968 PointerEventType.Press,
4969 PointerEventType.Release,
4970 PointerEventType.Press,
4971 PointerEventType.Release,
4972 PointerEventType.Press,
4973 PointerEventType.Release,
4974 )
4975 }
4976 }
4977
4978 /**
4979 * Child of a **clipped** parent uses .graphicsLayer { translationY } to offset OUTSIDE the
4980 * parent's clipped dimensions. Any touch input outside the clipped parent (including both
4981 * direct hits and indirect hits in the minimum touch target) should not be triggered in any
4982 * children. This test calculates the edges using the minimum touch target size (48.dp).
4983 */
4984 @Test
4985 fun clippedWithMinimumTouchTargetOverlap_shouldNotTriggerOverlappingClippedTouch() {
4986 val eventLog = mutableListOf<PointerEventType>()
4987 var innerOffsetBoxCoordinates: LayoutCoordinates? = null
4988 var parentBoxCoordinates: LayoutCoordinates? = null
4989 val latch = CountDownLatch(2)
4990
4991 var dpInPixel: Float? = null
4992
4993 rule.runOnUiThread {
4994 container.setContent {
4995 with(LocalDensity.current) { dpInPixel = 1.dp.toPx() }
4996 Column(Modifier.background(Color.Cyan).fillMaxSize()) {
4997 // Top Box
4998 Box(Modifier.background(Color.Gray).size(100.dp))
4999 // Bottom Box (parent)
5000 Box(
5001 Modifier.background(Color.Red)
5002 .size(40.dp)
5003 .clipToBounds()
5004 .onGloballyPositioned {
5005 parentBoxCoordinates = it
5006 latch.countDown()
5007 }
5008 ) {
5009 // Inner Bottom Box (this clipped child is the main box we are testing)
5010 Box(
5011 Modifier.size(40.dp)
5012 .background(Color.Green)
5013 // Moves the box outside of the clipped area of the parent
5014 .graphicsLayer { translationY = -10.dp.roundToPx().toFloat() }
5015 .pointerInput(Unit) {
5016 awaitPointerEventScope {
5017 while (true) {
5018 val event = awaitPointerEvent()
5019 event.changes[0].consume()
5020 eventLog += event.type
5021 }
5022 }
5023 }
5024 .onGloballyPositioned {
5025 innerOffsetBoxCoordinates = it
5026 latch.countDown()
5027 }
5028 )
5029 }
5030 }
5031 }
5032 }
5033 assertTrue(latch.await(1, TimeUnit.SECONDS))
5034
5035 val offsetBoxCoords: LayoutCoordinates = innerOffsetBoxCoordinates!!
5036 val parentBoxCoords: LayoutCoordinates = parentBoxCoordinates!!
5037
5038 val justOutsideMinimumTouchTargetOfClippedChild = dpInPixel!! * 5
5039 val edgeOfMinimumTouchTargetOfClippedChild = dpInPixel!! * 4
5040
5041 // Hits the top Box, but just outside the minimum touch target area of the bottom child Box
5042 // (which includes the offset into top box).
5043 dispatchTouchEvent(
5044 ACTION_DOWN,
5045 offsetBoxCoords,
5046 Offset(0f, -justOutsideMinimumTouchTargetOfClippedChild)
5047 )
5048 dispatchTouchEvent(
5049 ACTION_UP,
5050 offsetBoxCoords,
5051 Offset(0f, -justOutsideMinimumTouchTargetOfClippedChild)
5052 )
5053 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5054
5055 // Hits the top Box and the minimum touch target area edge of the bottom child Box (which
5056 // includes the offset into top box). Despite this being in the minimum touch area, it will
5057 // NOT trigger the event since it's in the clipped region.
5058 dispatchTouchEvent(
5059 ACTION_DOWN,
5060 offsetBoxCoords,
5061 Offset(0f, -edgeOfMinimumTouchTargetOfClippedChild)
5062 )
5063 dispatchTouchEvent(
5064 ACTION_UP,
5065 offsetBoxCoords,
5066 Offset(0f, -edgeOfMinimumTouchTargetOfClippedChild)
5067 )
5068
5069 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5070
5071 // Hits the top Box and a direct hit of the edge of the bottom child Box (which
5072 // includes the offset into top box). It will NOT trigger the event since it's in the
5073 // clipped region.
5074 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords)
5075 dispatchMouseEvent(ACTION_UP, offsetBoxCoords)
5076
5077 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5078
5079 // Hits the top Box and a direct hit of the bottom child Box (which
5080 // includes the offset into top box). It's one dp shy of the edge of the parent bottom box.
5081 // It will NOT trigger the event since it's still in the clipped region.
5082 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords, Offset(0f, -dpInPixel!!))
5083 dispatchMouseEvent(ACTION_UP, parentBoxCoords, Offset(0f, -dpInPixel!!))
5084
5085 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5086
5087 // Direct hit of edge of bottom parent box (and child box) in an unclipped region.
5088 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords)
5089 dispatchMouseEvent(ACTION_UP, parentBoxCoords)
5090
5091 rule.runOnUiThread {
5092 assertThat(eventLog)
5093 .containsExactly(
5094 PointerEventType.Press,
5095 PointerEventType.Release,
5096 )
5097 }
5098
5099 // Hits the bottom box in the unclipped region (farther down from edges).
5100 val topOfUnclipped = Offset(0f, (offsetBoxCoords.size.height / 2 + 1).toFloat())
5101 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, topOfUnclipped)
5102 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, topOfUnclipped)
5103
5104 // Continue to the bottom of the bottom Box
5105 val bottomOfBox = Offset(0f, (offsetBoxCoords.size.height - 1).toFloat())
5106 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, bottomOfBox)
5107 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, bottomOfBox)
5108
5109 // Now exit the bottom box
5110 val justBelow = Offset(0f, (offsetBoxCoords.size.height + 1).toFloat())
5111 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, justBelow)
5112 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, justBelow)
5113
5114 rule.runOnUiThread {
5115 assertThat(eventLog)
5116 .containsExactly(
5117 PointerEventType.Press,
5118 PointerEventType.Release,
5119 PointerEventType.Press,
5120 PointerEventType.Release,
5121 PointerEventType.Press,
5122 PointerEventType.Release,
5123 PointerEventType.Press,
5124 PointerEventType.Release,
5125 )
5126 }
5127 }
5128
5129 /**
5130 * Same as clippedWithMinimumTouchTargetOverlap_shouldNotTriggerOverlappingClippedTouch() but
5131 * uses .offset() instead of .graphicsLayer { translationY }.
5132 */
5133 @Test
5134 fun clippedWithMinimumTouchTargetOverlapViaOffset_shouldNotTriggerOverlappingClippedTouch() {
5135 val eventLog = mutableListOf<PointerEventType>()
5136 var innerOffsetBoxCoordinates: LayoutCoordinates? = null
5137 var parentBoxCoordinates: LayoutCoordinates? = null
5138 val latch = CountDownLatch(2)
5139
5140 var dpInPixel: Float? = null
5141
5142 rule.runOnUiThread {
5143 container.setContent {
5144 with(LocalDensity.current) { dpInPixel = 1.dp.toPx() }
5145 Column(Modifier.background(Color.Cyan).fillMaxSize()) {
5146 // Top Box
5147 Box(Modifier.background(Color.Gray).size(100.dp))
5148 // Bottom Box (parent)
5149 Box(
5150 Modifier.background(Color.Red)
5151 .size(40.dp)
5152 .clipToBounds()
5153 .onGloballyPositioned {
5154 parentBoxCoordinates = it
5155 latch.countDown()
5156 }
5157 ) {
5158 // Inner Bottom Box (this clipped child is the main box we are testing)
5159 Box(
5160 Modifier.size(40.dp)
5161 .background(Color.Green)
5162 // Moves the box outside of the clipped area of the parent
5163 .offset(x = 0.dp, y = (-10).dp)
5164 .pointerInput(Unit) {
5165 awaitPointerEventScope {
5166 while (true) {
5167 val event = awaitPointerEvent()
5168 event.changes[0].consume()
5169 eventLog += event.type
5170 }
5171 }
5172 }
5173 .onGloballyPositioned {
5174 innerOffsetBoxCoordinates = it
5175 latch.countDown()
5176 }
5177 )
5178 }
5179 }
5180 }
5181 }
5182 assertTrue(latch.await(1, TimeUnit.SECONDS))
5183
5184 val offsetBoxCoords: LayoutCoordinates = innerOffsetBoxCoordinates!!
5185 val parentBoxCoords: LayoutCoordinates = parentBoxCoordinates!!
5186
5187 val justOutsideMinimumTouchTargetOfClippedChild = dpInPixel!! * 5
5188 val edgeOfMinimumTouchTargetOfClippedChild = dpInPixel!! * 4
5189
5190 // Hits the top Box, but just outside the minimum touch target area of the bottom child Box
5191 // (which includes the offset into top box).
5192 dispatchTouchEvent(
5193 ACTION_DOWN,
5194 offsetBoxCoords,
5195 Offset(0f, -justOutsideMinimumTouchTargetOfClippedChild)
5196 )
5197 dispatchTouchEvent(
5198 ACTION_UP,
5199 offsetBoxCoords,
5200 Offset(0f, -justOutsideMinimumTouchTargetOfClippedChild)
5201 )
5202 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5203
5204 // Hit the top Box, but in the minimum touch target area of the bottom Box (that hit area
5205 // is WITHIN the clipped region but minimum area hit trumps clip and it will be triggered).
5206 // Note: This is not a direct hit of the bottom box.
5207
5208 // Hits the top Box and the minimum touch target area edge of the bottom child Box (which
5209 // includes the offset into top box). Despite this being in the minimum touch area, it will
5210 // NOT trigger the event since it's in the clipped region.
5211 dispatchTouchEvent(
5212 ACTION_DOWN,
5213 offsetBoxCoords,
5214 Offset(0f, -edgeOfMinimumTouchTargetOfClippedChild)
5215 )
5216 dispatchTouchEvent(
5217 ACTION_UP,
5218 offsetBoxCoords,
5219 Offset(0f, -edgeOfMinimumTouchTargetOfClippedChild)
5220 )
5221 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5222
5223 // Hits the top Box and a direct hit of the edge of the bottom child Box (which
5224 // includes the offset into top box). It will NOT trigger the event since it's in the
5225 // clipped region.
5226 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords)
5227 dispatchMouseEvent(ACTION_UP, offsetBoxCoords)
5228
5229 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5230
5231 // Hits the top Box and a direct hit of the bottom child Box (which
5232 // includes the offset into top box). It's one dp shy of the edge of the parent bottom box.
5233 // It will NOT trigger the event since it's still in the clipped region.
5234 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords, Offset(0f, -dpInPixel!!))
5235 dispatchMouseEvent(ACTION_UP, parentBoxCoords, Offset(0f, -dpInPixel!!))
5236
5237 rule.runOnUiThread { assertThat(eventLog).isEmpty() }
5238
5239 // Direct hit of edge of bottom parent box (and child box) in an unclipped region.
5240 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords)
5241 dispatchMouseEvent(ACTION_UP, parentBoxCoords)
5242
5243 rule.runOnUiThread {
5244 assertThat(eventLog)
5245 .containsExactly(
5246 PointerEventType.Press,
5247 PointerEventType.Release,
5248 )
5249 }
5250
5251 // Hits the bottom box in the unclipped region (farther down from edges).
5252 val topOfUnclipped = Offset(0f, (offsetBoxCoords.size.height / 2 + 1).toFloat())
5253 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, topOfUnclipped)
5254 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, topOfUnclipped)
5255
5256 // Continue to the bottom of the bottom Box
5257 val bottomOfBox = Offset(0f, (offsetBoxCoords.size.height - 1).toFloat())
5258 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, bottomOfBox)
5259 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, bottomOfBox)
5260
5261 // Now exit the bottom box
5262 val justBelow = Offset(0f, (offsetBoxCoords.size.height + 1).toFloat())
5263 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, justBelow)
5264 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, justBelow)
5265
5266 rule.runOnUiThread {
5267 assertThat(eventLog)
5268 .containsExactly(
5269 PointerEventType.Press,
5270 PointerEventType.Release,
5271 PointerEventType.Press,
5272 PointerEventType.Release,
5273 PointerEventType.Press,
5274 PointerEventType.Release,
5275 PointerEventType.Press,
5276 PointerEventType.Release,
5277 )
5278 }
5279 }
5280
5281 /**
5282 * Child of a parent uses .graphicsLayer { translationY } to offset OUTSIDE the parent's
5283 * dimensions. Any touch input outside the parent must be a direct hit to win (not indirect in
5284 * minimum test target size). This test calculates the edges using the minimum touch target size
5285 * (48.dp). Also, tests pruning a one node tree in HitTestResult.
5286 *
5287 * TODO(jjw): Write test for this in lower level test file.
5288 */
5289 @Test
5290 fun minimumTouchTargetOverlap_triggersDirectHitWithHigherOrder() {
5291 val eventLogTopBox = mutableListOf<PointerEventType>()
5292 val eventLogBottomBox = mutableListOf<PointerEventType>()
5293 var innerOffsetBoxCoordinates: LayoutCoordinates? = null
5294 var parentBoxCoordinates: LayoutCoordinates? = null
5295 val latch = CountDownLatch(2)
5296
5297 var dpInPixel: Float? = null
5298
5299 rule.runOnUiThread {
5300 container.setContent {
5301 with(LocalDensity.current) { dpInPixel = 1.dp.toPx() }
5302 Column(Modifier.background(Color.Cyan).fillMaxSize()) {
5303 // Top Box
5304 Box(
5305 Modifier.background(Color.Gray).size(100.dp).pointerInput(Unit) {
5306 awaitPointerEventScope {
5307 while (true) {
5308 val event = awaitPointerEvent()
5309 event.changes[0].consume()
5310 eventLogTopBox += event.type
5311 }
5312 }
5313 }
5314 )
5315 // Bottom Box (parent)
5316 Box(
5317 Modifier.background(Color.Red).size(40.dp).onGloballyPositioned {
5318 parentBoxCoordinates = it
5319 latch.countDown()
5320 }
5321 ) {
5322 // Inner Bottom Box (main box we are testing)
5323 Box(
5324 Modifier.size(40.dp)
5325 .background(Color.Green)
5326 // Moves the box outside of the parent
5327 .graphicsLayer { translationY = -10.dp.roundToPx().toFloat() }
5328 .pointerInput(Unit) {
5329 awaitPointerEventScope {
5330 while (true) {
5331 val event = awaitPointerEvent()
5332 event.changes[0].consume()
5333 eventLogBottomBox += event.type
5334 }
5335 }
5336 }
5337 .onGloballyPositioned {
5338 innerOffsetBoxCoordinates = it
5339 latch.countDown()
5340 }
5341 )
5342 }
5343 }
5344 }
5345 }
5346 assertTrue(latch.await(1, TimeUnit.SECONDS))
5347
5348 val offsetBoxCoords: LayoutCoordinates = innerOffsetBoxCoordinates!!
5349 val parentBoxCoords: LayoutCoordinates = parentBoxCoordinates!!
5350
5351 val justOutsideMinimumTouchTargetOfChildBox = dpInPixel!! * 5
5352 val edgeOfMinimumTouchTargetOfChildBox = dpInPixel!! * 4
5353
5354 // Hits the top Box, but just outside the minimum touch target area of the bottom child Box
5355 // (which includes the offset into top box).
5356 dispatchTouchEvent(
5357 ACTION_DOWN,
5358 offsetBoxCoords,
5359 Offset(0f, -justOutsideMinimumTouchTargetOfChildBox)
5360 )
5361 dispatchTouchEvent(
5362 ACTION_UP,
5363 offsetBoxCoords,
5364 Offset(0f, -justOutsideMinimumTouchTargetOfChildBox)
5365 )
5366
5367 rule.runOnUiThread {
5368 assertThat(eventLogTopBox)
5369 .containsExactly(
5370 PointerEventType.Press,
5371 PointerEventType.Release,
5372 )
5373 assertThat(eventLogBottomBox).isEmpty()
5374 }
5375
5376 // Hit the top Box, but in the minimum touch target area of the bottom Box. Because this is
5377 // not a direct hit on the bottom box, the top box still wins.
5378 dispatchTouchEvent(
5379 ACTION_DOWN,
5380 offsetBoxCoords,
5381 Offset(0f, -edgeOfMinimumTouchTargetOfChildBox)
5382 )
5383 dispatchTouchEvent(
5384 ACTION_UP,
5385 offsetBoxCoords,
5386 Offset(0f, -edgeOfMinimumTouchTargetOfChildBox)
5387 )
5388
5389 rule.runOnUiThread {
5390 assertThat(eventLogTopBox)
5391 .containsExactly(
5392 PointerEventType.Press,
5393 PointerEventType.Release,
5394 PointerEventType.Press,
5395 PointerEventType.Release,
5396 )
5397 assertThat(eventLogBottomBox).isEmpty()
5398 }
5399
5400 // Hits the top Box and a direct hit of the edge of the bottom child Box (which
5401 // includes the offset into top box). Bottom child will get the event since it has higher
5402 // order of the two direct hits.
5403 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords)
5404 dispatchMouseEvent(ACTION_UP, offsetBoxCoords)
5405
5406 rule.runOnUiThread {
5407 assertThat(eventLogTopBox)
5408 .containsExactly(
5409 PointerEventType.Press,
5410 PointerEventType.Release,
5411 PointerEventType.Press,
5412 PointerEventType.Release,
5413 )
5414 assertThat(eventLogBottomBox)
5415 .containsExactly(
5416 PointerEventType.Enter,
5417 PointerEventType.Press,
5418 PointerEventType.Release,
5419 )
5420 }
5421
5422 // Still a direct hit on bottom child box, so it wins.
5423 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords, Offset(0f, -dpInPixel!!))
5424 dispatchMouseEvent(ACTION_UP, parentBoxCoords, Offset(0f, -dpInPixel!!))
5425
5426 rule.runOnUiThread {
5427 assertThat(eventLogTopBox)
5428 .containsExactly(
5429 PointerEventType.Press,
5430 PointerEventType.Release,
5431 PointerEventType.Press,
5432 PointerEventType.Release,
5433 )
5434 assertThat(eventLogBottomBox)
5435 .containsExactly(
5436 PointerEventType.Enter,
5437 PointerEventType.Press,
5438 PointerEventType.Release,
5439 PointerEventType.Press,
5440 PointerEventType.Release,
5441 )
5442 }
5443
5444 // Direct hit of edge of bottom parent box (and child box).
5445 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords)
5446 dispatchMouseEvent(ACTION_UP, parentBoxCoords)
5447
5448 rule.runOnUiThread {
5449 assertThat(eventLogTopBox)
5450 .containsExactly(
5451 PointerEventType.Press,
5452 PointerEventType.Release,
5453 PointerEventType.Press,
5454 PointerEventType.Release,
5455 )
5456 assertThat(eventLogBottomBox)
5457 .containsExactly(
5458 PointerEventType.Enter,
5459 PointerEventType.Press,
5460 PointerEventType.Release,
5461 PointerEventType.Press,
5462 PointerEventType.Release,
5463 PointerEventType.Press,
5464 PointerEventType.Release,
5465 )
5466 }
5467
5468 // Hits the bottom box (farther down from edges).
5469 val topOfUnclipped = Offset(0f, (offsetBoxCoords.size.height / 2 + 1).toFloat())
5470 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, topOfUnclipped)
5471 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, topOfUnclipped)
5472
5473 // Continue to the bottom of the bottom Box
5474 val bottomOfBox = Offset(0f, (offsetBoxCoords.size.height - 1).toFloat())
5475 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, bottomOfBox)
5476 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, bottomOfBox)
5477
5478 // Now exit the bottom box
5479 val justBelow = Offset(0f, (offsetBoxCoords.size.height + 1).toFloat())
5480 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords, justBelow)
5481 dispatchMouseEvent(ACTION_UP, offsetBoxCoords, justBelow)
5482
5483 rule.runOnUiThread {
5484 assertThat(eventLogTopBox)
5485 .containsExactly(
5486 PointerEventType.Press,
5487 PointerEventType.Release,
5488 PointerEventType.Press,
5489 PointerEventType.Release,
5490 )
5491 assertThat(eventLogBottomBox)
5492 .containsExactly(
5493 PointerEventType.Enter,
5494 PointerEventType.Press,
5495 PointerEventType.Release,
5496 PointerEventType.Press,
5497 PointerEventType.Release,
5498 PointerEventType.Press,
5499 PointerEventType.Release,
5500 PointerEventType.Press,
5501 PointerEventType.Release,
5502 PointerEventType.Press,
5503 PointerEventType.Release,
5504 PointerEventType.Press,
5505 PointerEventType.Release,
5506 )
5507 }
5508 }
5509
5510 /**
5511 * Tests proper pointer input results on three nested boxes with different dimensions:
5512 * - Large box 5x the size of the small box
5513 * - Medium box 3x size of small box (centered in large box as child)
5514 * - Small box larger than minimum touch target size (centered in medium box as child)
5515 *
5516 * In this test, [Modifier.offset()] is used to center the boxes! The test is meant to test
5517 * modifiers that change the LayoutNode's location while also being large enough to not hit any
5518 * logic associated with the minimum touch target size.
5519 *
5520 * Input hits top left area of large box (triggers large box), the top left area of the medium
5521 * box (triggers large and medium), and the center of the small box (triggers all three boxes).
5522 *
5523 * This test is a high-level implementation of the tests using
5524 * [PointerInputEventProcessorTest.process_partialTreeHits].
5525 */
5526 @Test
5527 fun inputOnNestedBoxesLargerThanMinTouchPlacedViaOffset_simpleInput_properlyTriggers() {
5528 val eventLogLargeBox = mutableListOf<PointerEventType>()
5529 val eventLogMediumBox = mutableListOf<PointerEventType>()
5530 val eventLogSmallBox = mutableListOf<PointerEventType>()
5531
5532 var layoutCoordsLargeBox: LayoutCoordinates? = null
5533
5534 // Changes dynamically to size specified by minimumTouchTargetSize.
5535 var minimumTouchTargetSizeDp: Dp
5536
5537 var dimensionsLargeBoxDp: Dp
5538 var dimensionsMediumBoxDp: Dp
5539 var dimensionsSmallBoxDp: Dp
5540
5541 var offsetAmountDp: Dp
5542
5543 var hitAllThreeBoxesFloat: Float? = null
5544 var hitLargeAndMediumBoxesFloat: Float? = null
5545 var hitLargeBoxOnlyFloat: Float? = null
5546
5547 val latch = CountDownLatch(1)
5548
5549 rule.runOnUiThread {
5550 container.setContent {
5551 with(LocalViewConfiguration.current) {
5552 minimumTouchTargetSizeDp = this.minimumTouchTargetSize.width
5553 }
5554
5555 with(LocalDensity.current) {
5556 val baseSize = roundUpDpToNearestHundred(minimumTouchTargetSizeDp)
5557 dimensionsSmallBoxDp = baseSize
5558 dimensionsMediumBoxDp = baseSize * 3
5559 dimensionsLargeBoxDp = baseSize * 5
5560
5561 // Just happens to be the same dimensions as the bottom box
5562 offsetAmountDp = dimensionsSmallBoxDp
5563
5564 hitAllThreeBoxesFloat = dimensionsLargeBoxDp.toPx() / 2
5565 hitLargeAndMediumBoxesFloat = dimensionsMediumBoxDp.toPx() / 2
5566 hitLargeBoxOnlyFloat = dimensionsSmallBoxDp.toPx() / 2
5567 }
5568
5569 // Large Box (5x the size of the small box)
5570 Box(
5571 Modifier.background(Color.Cyan)
5572 .size(dimensionsLargeBoxDp)
5573 .pointerInput(Unit) {
5574 awaitPointerEventScope {
5575 while (true) {
5576 val event = awaitPointerEvent()
5577 event.changes[0].consume()
5578 eventLogLargeBox += event.type
5579 }
5580 }
5581 }
5582 .onGloballyPositioned {
5583 layoutCoordsLargeBox = it
5584 latch.countDown()
5585 }
5586 ) {
5587 // Medium Box (3x the size of the small box)
5588 Box(
5589 Modifier.offset(offsetAmountDp, offsetAmountDp)
5590 .background(Color.Gray)
5591 .size(dimensionsMediumBoxDp)
5592 .pointerInput(Unit) {
5593 awaitPointerEventScope {
5594 while (true) {
5595 val event = awaitPointerEvent()
5596 event.changes[0].consume()
5597 eventLogMediumBox += event.type
5598 }
5599 }
5600 }
5601 ) {
5602 // Small Box
5603 Box(
5604 Modifier.offset(offsetAmountDp, offsetAmountDp)
5605 .background(Color.Red)
5606 .size(dimensionsSmallBoxDp)
5607 .pointerInput(Unit) {
5608 awaitPointerEventScope {
5609 while (true) {
5610 val event = awaitPointerEvent()
5611 event.changes[0].consume()
5612 eventLogSmallBox += event.type
5613 }
5614 }
5615 }
5616 )
5617 }
5618 }
5619 }
5620 }
5621 assertTrue(latch.await(1, TimeUnit.SECONDS))
5622
5623 val topOffsetBoxCoords: LayoutCoordinates = layoutCoordsLargeBox!!
5624
5625 // Hits the large box only (outside of the medium and small boxes [child, grandchild]).
5626 dispatchTouchEvent(
5627 ACTION_DOWN,
5628 topOffsetBoxCoords,
5629 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
5630 )
5631 dispatchTouchEvent(
5632 ACTION_UP,
5633 topOffsetBoxCoords,
5634 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
5635 )
5636
5637 rule.runOnUiThread {
5638 assertThat(eventLogLargeBox)
5639 .containsExactly(
5640 PointerEventType.Press,
5641 PointerEventType.Release,
5642 )
5643 assertThat(eventLogMediumBox).isEmpty()
5644 assertThat(eventLogSmallBox).isEmpty()
5645 }
5646
5647 // Hits the medium boxes inside large box (but of small box [child]).
5648 dispatchTouchEvent(
5649 ACTION_DOWN,
5650 topOffsetBoxCoords,
5651 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
5652 )
5653 dispatchTouchEvent(
5654 ACTION_UP,
5655 topOffsetBoxCoords,
5656 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
5657 )
5658
5659 rule.runOnUiThread {
5660 assertThat(eventLogLargeBox)
5661 .containsExactly(
5662 PointerEventType.Press,
5663 PointerEventType.Release,
5664 PointerEventType.Press,
5665 PointerEventType.Release,
5666 )
5667 assertThat(eventLogMediumBox)
5668 .containsExactly(
5669 PointerEventType.Press,
5670 PointerEventType.Release,
5671 )
5672 assertThat(eventLogSmallBox).isEmpty()
5673 }
5674
5675 // Hits the small boxes inside medium box inside large box (that is, hits all).
5676 dispatchTouchEvent(
5677 ACTION_DOWN,
5678 topOffsetBoxCoords,
5679 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
5680 )
5681 dispatchTouchEvent(
5682 ACTION_UP,
5683 topOffsetBoxCoords,
5684 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
5685 )
5686
5687 rule.runOnUiThread {
5688 assertThat(eventLogLargeBox)
5689 .containsExactly(
5690 PointerEventType.Press,
5691 PointerEventType.Release,
5692 PointerEventType.Press,
5693 PointerEventType.Release,
5694 PointerEventType.Press,
5695 PointerEventType.Release,
5696 )
5697 assertThat(eventLogMediumBox)
5698 .containsExactly(
5699 PointerEventType.Press,
5700 PointerEventType.Release,
5701 PointerEventType.Press,
5702 PointerEventType.Release,
5703 )
5704 assertThat(eventLogSmallBox)
5705 .containsExactly(
5706 PointerEventType.Press,
5707 PointerEventType.Release,
5708 )
5709 }
5710 }
5711
5712 /**
5713 * Same test as
5714 * [inputOnNestedBoxesLargerThanMinTouchPlacedViaOffset_simpleInput_properlyTriggers()] but uses
5715 * [Modifier.graphicsLayer()] instead of [Modifier.offset()] to move the LayoutNodes.
5716 */
5717 @Test
5718 fun inputOnNestedBoxesLargerThanMinTouchPlacedViaGraphicsLayer_simpleInput_properlyTriggers() {
5719 val eventLogLargeBox = mutableListOf<PointerEventType>()
5720 val eventLogMediumBox = mutableListOf<PointerEventType>()
5721 val eventLogSmallBox = mutableListOf<PointerEventType>()
5722
5723 var layoutCoordsLargeBox: LayoutCoordinates? = null
5724
5725 // Changes dynamically to size specified by minimumTouchTargetSize.
5726 var minimumTouchTargetSizeDp: Dp
5727
5728 var dimensionsLargeBoxDp: Dp
5729 var dimensionsMediumBoxDp: Dp
5730 var dimensionsSmallBoxDp: Dp
5731
5732 var offsetAmountDp: Dp
5733
5734 var hitAllThreeBoxesFloat: Float? = null
5735 var hitLargeAndMediumBoxesFloat: Float? = null
5736 var hitLargeBoxOnlyFloat: Float? = null
5737
5738 val latch = CountDownLatch(1)
5739
5740 rule.runOnUiThread {
5741 container.setContent {
5742 with(LocalViewConfiguration.current) {
5743 minimumTouchTargetSizeDp = this.minimumTouchTargetSize.width
5744 }
5745
5746 with(LocalDensity.current) {
5747 val baseSize = roundUpDpToNearestHundred(minimumTouchTargetSizeDp)
5748 dimensionsSmallBoxDp = baseSize
5749 dimensionsMediumBoxDp = baseSize * 3
5750 dimensionsLargeBoxDp = baseSize * 5
5751
5752 // Just happens to be the same dimensions as the bottom box
5753 offsetAmountDp = dimensionsSmallBoxDp
5754
5755 hitAllThreeBoxesFloat = dimensionsLargeBoxDp.toPx() / 2
5756 hitLargeAndMediumBoxesFloat = dimensionsMediumBoxDp.toPx() / 2
5757 hitLargeBoxOnlyFloat = dimensionsSmallBoxDp.toPx() / 2
5758 }
5759
5760 // Large Box (5x the size of the small box)
5761 Box(
5762 Modifier.background(Color.Cyan)
5763 .size(dimensionsLargeBoxDp)
5764 .pointerInput(Unit) {
5765 awaitPointerEventScope {
5766 while (true) {
5767 val event = awaitPointerEvent()
5768 event.changes[0].consume()
5769 eventLogLargeBox += event.type
5770 }
5771 }
5772 }
5773 .onGloballyPositioned {
5774 layoutCoordsLargeBox = it
5775 latch.countDown()
5776 }
5777 ) {
5778 // Medium Box (3x the size of the small box)
5779 Box(
5780 Modifier.graphicsLayer {
5781 translationY = offsetAmountDp.toPx()
5782 translationX = offsetAmountDp.toPx()
5783 }
5784 .background(Color.Gray)
5785 .size(dimensionsMediumBoxDp)
5786 .pointerInput(Unit) {
5787 awaitPointerEventScope {
5788 while (true) {
5789 val event = awaitPointerEvent()
5790 event.changes[0].consume()
5791 eventLogMediumBox += event.type
5792 }
5793 }
5794 }
5795 ) {
5796 // Small Box
5797 Box(
5798 Modifier.graphicsLayer {
5799 translationY = offsetAmountDp.toPx()
5800 translationX = offsetAmountDp.toPx()
5801 }
5802 .background(Color.Red)
5803 .size(dimensionsSmallBoxDp)
5804 .pointerInput(Unit) {
5805 awaitPointerEventScope {
5806 while (true) {
5807 val event = awaitPointerEvent()
5808 event.changes[0].consume()
5809 eventLogSmallBox += event.type
5810 }
5811 }
5812 }
5813 )
5814 }
5815 }
5816 }
5817 }
5818 assertTrue(latch.await(1, TimeUnit.SECONDS))
5819
5820 val topOffsetBoxCoords: LayoutCoordinates = layoutCoordsLargeBox!!
5821
5822 // Hits the large box only (outside of the medium and small boxes [child, grandchild]).
5823 dispatchTouchEvent(
5824 ACTION_DOWN,
5825 topOffsetBoxCoords,
5826 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
5827 )
5828 dispatchTouchEvent(
5829 ACTION_UP,
5830 topOffsetBoxCoords,
5831 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
5832 )
5833
5834 rule.runOnUiThread {
5835 assertThat(eventLogLargeBox)
5836 .containsExactly(
5837 PointerEventType.Press,
5838 PointerEventType.Release,
5839 )
5840 assertThat(eventLogMediumBox).isEmpty()
5841 assertThat(eventLogSmallBox).isEmpty()
5842 }
5843
5844 // Hits the medium boxes inside large box (but of small box [child]).
5845 dispatchTouchEvent(
5846 ACTION_DOWN,
5847 topOffsetBoxCoords,
5848 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
5849 )
5850 dispatchTouchEvent(
5851 ACTION_UP,
5852 topOffsetBoxCoords,
5853 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
5854 )
5855
5856 rule.runOnUiThread {
5857 assertThat(eventLogLargeBox)
5858 .containsExactly(
5859 PointerEventType.Press,
5860 PointerEventType.Release,
5861 PointerEventType.Press,
5862 PointerEventType.Release,
5863 )
5864 assertThat(eventLogMediumBox)
5865 .containsExactly(
5866 PointerEventType.Press,
5867 PointerEventType.Release,
5868 )
5869 assertThat(eventLogSmallBox).isEmpty()
5870 }
5871
5872 // Hits the small boxes inside medium box inside large box (that is, hits all).
5873 dispatchTouchEvent(
5874 ACTION_DOWN,
5875 topOffsetBoxCoords,
5876 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
5877 )
5878 dispatchTouchEvent(
5879 ACTION_UP,
5880 topOffsetBoxCoords,
5881 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
5882 )
5883
5884 rule.runOnUiThread {
5885 assertThat(eventLogLargeBox)
5886 .containsExactly(
5887 PointerEventType.Press,
5888 PointerEventType.Release,
5889 PointerEventType.Press,
5890 PointerEventType.Release,
5891 PointerEventType.Press,
5892 PointerEventType.Release,
5893 )
5894 assertThat(eventLogMediumBox)
5895 .containsExactly(
5896 PointerEventType.Press,
5897 PointerEventType.Release,
5898 PointerEventType.Press,
5899 PointerEventType.Release,
5900 )
5901 assertThat(eventLogSmallBox)
5902 .containsExactly(
5903 PointerEventType.Press,
5904 PointerEventType.Release,
5905 )
5906 }
5907 }
5908
5909 /**
5910 * Same test as
5911 * [inputOnNestedBoxesLargerThanMinTouchPlacedViaOffset_simpleInput_properlyTriggers()] but uses
5912 * [Modifier.padding()] instead of [Modifier.offset()] to move the LayoutNodes.
5913 */
5914 @Test
5915 fun inputOnNestedBoxesLargerThanMinTouchPlacedViaPadding_simpleInput_properlyTriggers() {
5916 val eventLogLargeBox = mutableListOf<PointerEventType>()
5917 val eventLogMediumBox = mutableListOf<PointerEventType>()
5918 val eventLogSmallBox = mutableListOf<PointerEventType>()
5919
5920 var layoutCoordsLargeBox: LayoutCoordinates? = null
5921
5922 // Changes dynamically to size specified by minimumTouchTargetSize.
5923 var minimumTouchTargetSizeDp: Dp
5924
5925 var dimensionsLargeBoxDp: Dp
5926 var dimensionsMediumBoxDp: Dp
5927 var dimensionsSmallBoxDp: Dp
5928
5929 var offsetAmountDp: Dp
5930
5931 var hitAllThreeBoxesFloat: Float? = null
5932 var hitLargeAndMediumBoxesFloat: Float? = null
5933 var hitLargeBoxOnlyFloat: Float? = null
5934
5935 val latch = CountDownLatch(1)
5936
5937 rule.runOnUiThread {
5938 container.setContent {
5939 with(LocalViewConfiguration.current) {
5940 minimumTouchTargetSizeDp = this.minimumTouchTargetSize.width
5941 }
5942
5943 with(LocalDensity.current) {
5944 val baseSize = roundUpDpToNearestHundred(minimumTouchTargetSizeDp)
5945 dimensionsSmallBoxDp = baseSize
5946 dimensionsMediumBoxDp = baseSize * 3
5947 dimensionsLargeBoxDp = baseSize * 5
5948
5949 // Just happens to be the same dimensions as the bottom box
5950 offsetAmountDp = dimensionsSmallBoxDp
5951
5952 hitAllThreeBoxesFloat = dimensionsLargeBoxDp.toPx() / 2
5953 hitLargeAndMediumBoxesFloat = dimensionsMediumBoxDp.toPx() / 2
5954 hitLargeBoxOnlyFloat = dimensionsSmallBoxDp.toPx() / 2
5955 }
5956
5957 // Large Box (5x the size of the small box)
5958 Box(
5959 Modifier.background(Color.Cyan)
5960 .size(dimensionsLargeBoxDp)
5961 .pointerInput(Unit) {
5962 awaitPointerEventScope {
5963 while (true) {
5964 val event = awaitPointerEvent()
5965 event.changes[0].consume()
5966 eventLogLargeBox += event.type
5967 }
5968 }
5969 }
5970 .onGloballyPositioned {
5971 layoutCoordsLargeBox = it
5972 latch.countDown()
5973 }
5974 ) {
5975 // Medium Box (3x the size of the small box)
5976 Box(
5977 Modifier.padding(start = offsetAmountDp, top = offsetAmountDp)
5978 .background(Color.Gray)
5979 .size(dimensionsMediumBoxDp)
5980 .pointerInput(Unit) {
5981 awaitPointerEventScope {
5982 while (true) {
5983 val event = awaitPointerEvent()
5984 event.changes[0].consume()
5985 eventLogMediumBox += event.type
5986 }
5987 }
5988 }
5989 ) {
5990 // Small Box
5991 Box(
5992 Modifier.padding(start = offsetAmountDp, top = offsetAmountDp)
5993 .background(Color.Red)
5994 .size(dimensionsSmallBoxDp)
5995 .pointerInput(Unit) {
5996 awaitPointerEventScope {
5997 while (true) {
5998 val event = awaitPointerEvent()
5999 event.changes[0].consume()
6000 eventLogSmallBox += event.type
6001 }
6002 }
6003 }
6004 )
6005 }
6006 }
6007 }
6008 }
6009 assertTrue(latch.await(1, TimeUnit.SECONDS))
6010
6011 val topOffsetBoxCoords: LayoutCoordinates = layoutCoordsLargeBox!!
6012
6013 // Hits the large box only (outside of the medium and small boxes [child, grandchild]).
6014 dispatchTouchEvent(
6015 ACTION_DOWN,
6016 topOffsetBoxCoords,
6017 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
6018 )
6019 dispatchTouchEvent(
6020 ACTION_UP,
6021 topOffsetBoxCoords,
6022 Offset(hitLargeBoxOnlyFloat!!, hitLargeBoxOnlyFloat!!)
6023 )
6024
6025 rule.runOnUiThread {
6026 assertThat(eventLogLargeBox)
6027 .containsExactly(
6028 PointerEventType.Press,
6029 PointerEventType.Release,
6030 )
6031 assertThat(eventLogMediumBox).isEmpty()
6032 assertThat(eventLogSmallBox).isEmpty()
6033 }
6034
6035 // Hits the medium boxes inside large box (but of small box [child]).
6036 dispatchTouchEvent(
6037 ACTION_DOWN,
6038 topOffsetBoxCoords,
6039 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
6040 )
6041 dispatchTouchEvent(
6042 ACTION_UP,
6043 topOffsetBoxCoords,
6044 Offset(hitLargeAndMediumBoxesFloat!!, hitLargeAndMediumBoxesFloat!!)
6045 )
6046
6047 rule.runOnUiThread {
6048 assertThat(eventLogLargeBox)
6049 .containsExactly(
6050 PointerEventType.Press,
6051 PointerEventType.Release,
6052 PointerEventType.Press,
6053 PointerEventType.Release,
6054 )
6055 assertThat(eventLogMediumBox)
6056 .containsExactly(
6057 PointerEventType.Press,
6058 PointerEventType.Release,
6059 )
6060 assertThat(eventLogSmallBox).isEmpty()
6061 }
6062
6063 // Hits the small boxes inside medium box inside large box (that is, hits all).
6064 dispatchTouchEvent(
6065 ACTION_DOWN,
6066 topOffsetBoxCoords,
6067 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
6068 )
6069 dispatchTouchEvent(
6070 ACTION_UP,
6071 topOffsetBoxCoords,
6072 Offset(hitAllThreeBoxesFloat!!, hitAllThreeBoxesFloat!!)
6073 )
6074
6075 rule.runOnUiThread {
6076 assertThat(eventLogLargeBox)
6077 .containsExactly(
6078 PointerEventType.Press,
6079 PointerEventType.Release,
6080 PointerEventType.Press,
6081 PointerEventType.Release,
6082 PointerEventType.Press,
6083 PointerEventType.Release,
6084 )
6085 assertThat(eventLogMediumBox)
6086 .containsExactly(
6087 PointerEventType.Press,
6088 PointerEventType.Release,
6089 PointerEventType.Press,
6090 PointerEventType.Release,
6091 )
6092 assertThat(eventLogSmallBox)
6093 .containsExactly(
6094 PointerEventType.Press,
6095 PointerEventType.Release,
6096 )
6097 }
6098 }
6099
6100 /**
6101 * Same as minimumTouchTargetOverlap_triggersDirectHitWithHigherOrder() but uses .offset()
6102 * instead of .graphicsLayer { translationY }.
6103 */
6104 @Test
6105 fun minimumTouchTargetOverlapWithOffset_triggersDirectHitWithHigherOrder() {
6106 val eventLogTopBox = mutableListOf<PointerEventType>()
6107 val eventLogBottomBox = mutableListOf<PointerEventType>()
6108 var innerOffsetBoxCoordinates: LayoutCoordinates? = null
6109 var parentBoxCoordinates: LayoutCoordinates? = null
6110 val latch = CountDownLatch(2)
6111
6112 var dpInPixel: Float? = null
6113
6114 rule.runOnUiThread {
6115 container.setContent {
6116 with(LocalDensity.current) { dpInPixel = 1.dp.toPx() }
6117 Column(Modifier.background(Color.Cyan).fillMaxSize()) {
6118 // Top Box
6119 Box(
6120 Modifier.background(Color.Gray).size(100.dp).pointerInput(Unit) {
6121 awaitPointerEventScope {
6122 while (true) {
6123 val event = awaitPointerEvent()
6124 event.changes[0].consume()
6125 eventLogTopBox += event.type
6126 }
6127 }
6128 }
6129 )
6130 // Bottom Box (parent)
6131 Box(
6132 Modifier.background(Color.Red).size(40.dp).onGloballyPositioned {
6133 parentBoxCoordinates = it
6134 latch.countDown()
6135 }
6136 ) {
6137 // Inner Bottom Box (main box we are testing)
6138 Box(
6139 Modifier.size(40.dp)
6140 .background(Color.Green)
6141 // Moves the box outside of the parent
6142 .offset(x = 0.dp, y = (-10).dp)
6143 .pointerInput(Unit) {
6144 awaitPointerEventScope {
6145 while (true) {
6146 val event = awaitPointerEvent()
6147 event.changes[0].consume()
6148 eventLogBottomBox += event.type
6149 }
6150 }
6151 }
6152 .onGloballyPositioned {
6153 innerOffsetBoxCoordinates = it
6154 latch.countDown()
6155 }
6156 )
6157 }
6158 }
6159 }
6160 }
6161 assertTrue(latch.await(1, TimeUnit.SECONDS))
6162
6163 val offsetBoxCoords: LayoutCoordinates = innerOffsetBoxCoordinates!!
6164 val parentBoxCoords: LayoutCoordinates = parentBoxCoordinates!!
6165
6166 val justOutsideMinimumTouchTargetOfChildBox = dpInPixel!! * 5
6167 val edgeOfMinimumTouchTargetOfChildBox = dpInPixel!! * 4
6168
6169 // Hits the top Box, but just outside the minimum touch target area of the bottom child Box
6170 // (which includes the offset into top box).
6171 dispatchTouchEvent(
6172 ACTION_DOWN,
6173 offsetBoxCoords,
6174 Offset(0f, -justOutsideMinimumTouchTargetOfChildBox)
6175 )
6176 dispatchTouchEvent(
6177 ACTION_UP,
6178 offsetBoxCoords,
6179 Offset(0f, -justOutsideMinimumTouchTargetOfChildBox)
6180 )
6181
6182 rule.runOnUiThread {
6183 assertThat(eventLogTopBox)
6184 .containsExactly(
6185 PointerEventType.Press,
6186 PointerEventType.Release,
6187 )
6188 assertThat(eventLogBottomBox).isEmpty()
6189 }
6190
6191 // Hit the top Box, but in the minimum touch target area of the bottom Box. Because this is
6192 // not a direct hit on the bottom box, the top box still wins.
6193 dispatchTouchEvent(
6194 ACTION_DOWN,
6195 offsetBoxCoords,
6196 Offset(0f, -edgeOfMinimumTouchTargetOfChildBox)
6197 )
6198 dispatchTouchEvent(
6199 ACTION_UP,
6200 offsetBoxCoords,
6201 Offset(0f, -edgeOfMinimumTouchTargetOfChildBox)
6202 )
6203
6204 rule.runOnUiThread {
6205 assertThat(eventLogTopBox)
6206 .containsExactly(
6207 PointerEventType.Press,
6208 PointerEventType.Release,
6209 PointerEventType.Press,
6210 PointerEventType.Release,
6211 )
6212 assertThat(eventLogBottomBox).isEmpty()
6213 }
6214
6215 // Hits the top Box and a direct hit of the edge of the bottom child Box (which
6216 // includes the offset into top box). Bottom child will get the event since it has higher
6217 // order of the two direct hits.
6218 dispatchMouseEvent(ACTION_DOWN, offsetBoxCoords)
6219 dispatchMouseEvent(ACTION_UP, offsetBoxCoords)
6220
6221 rule.runOnUiThread {
6222 assertThat(eventLogTopBox)
6223 .containsExactly(
6224 PointerEventType.Press,
6225 PointerEventType.Release,
6226 PointerEventType.Press,
6227 PointerEventType.Release,
6228 )
6229 assertThat(eventLogBottomBox)
6230 .containsExactly(
6231 PointerEventType.Enter,
6232 PointerEventType.Press,
6233 PointerEventType.Release,
6234 )
6235 }
6236
6237 // Still a direct hit on bottom child box, so it wins.
6238 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords, Offset(0f, -dpInPixel!!))
6239 dispatchMouseEvent(ACTION_UP, parentBoxCoords, Offset(0f, -dpInPixel!!))
6240
6241 rule.runOnUiThread {
6242 assertThat(eventLogTopBox)
6243 .containsExactly(
6244 PointerEventType.Press,
6245 PointerEventType.Release,
6246 PointerEventType.Press,
6247 PointerEventType.Release,
6248 )
6249 assertThat(eventLogBottomBox)
6250 .containsExactly(
6251 PointerEventType.Enter,
6252 PointerEventType.Press,
6253 PointerEventType.Release,
6254 PointerEventType.Press,
6255 PointerEventType.Release,
6256 )
6257 }
6258
6259 // Direct hit of edge of bottom parent box (and child box).
6260 dispatchMouseEvent(ACTION_DOWN, parentBoxCoords)
6261 dispatchMouseEvent(ACTION_UP, parentBoxCoords)
6262
6263 rule.runOnUiThread {
6264 assertThat(eventLogTopBox)
6265 .containsExactly(
6266 PointerEventType.Press,
6267 PointerEventType.Release,
6268 PointerEventType.Press,
6269 PointerEventType.Release,
6270 )
6271 assertThat(eventLogBottomBox)
6272 .containsExactly(
6273 PointerEventType.Enter,
6274 PointerEventType.Press,
6275 PointerEventType.Release,
6276 PointerEventType.Press,
6277 PointerEventType.Release,
6278 PointerEventType.Press,
6279 PointerEventType.Release,
6280 )
6281 }
6282 }
6283
6284 /**
6285 * Touch events occur between two boxes that are both less than the minimum touch target size.
6286 * Tests an event directly between two boxes, then on each side of that border to make sure the
6287 * correct box event handlers are triggered.
6288 */
6289 @Test
6290 fun twoMinimumTouchTargetsAdjacent_nonDirectHitsBetweenTwo_triggersAppropriateBox() {
6291 var containingColumnPointerEventCount = 0
6292 var topBoxPointerEventCount = 0
6293 var bottomBoxPointerEventCount = 0
6294
6295 val spaceBetweenElementsInColumnDp: Dp = 8.dp
6296 var spaceBetweenElementsInColumnPixel: Float? = null
6297 var dpInPixel: Float? = null
6298
6299 var bottomBoxCoordinates: LayoutCoordinates? = null
6300 val latch = CountDownLatch(1)
6301 rule.runOnUiThread {
6302 container.setContent {
6303 with(LocalDensity.current) {
6304 dpInPixel = 1.dp.toPx()
6305 spaceBetweenElementsInColumnPixel = spaceBetweenElementsInColumnDp.toPx()
6306 }
6307 Column(
6308 Modifier.fillMaxSize().background(Color.Red).pointerInput(Unit) {
6309 awaitPointerEventScope {
6310 while (true) {
6311 awaitPointerEvent()
6312 containingColumnPointerEventCount++
6313 }
6314 }
6315 },
6316 verticalArrangement = Arrangement.spacedBy(spaceBetweenElementsInColumnDp)
6317 ) {
6318 Box(
6319 Modifier.size(40.dp) // Below minimum touch target 48.dp
6320 .background(Color.Green)
6321 .pointerInput(Unit) {
6322 awaitPointerEventScope {
6323 while (true) {
6324 awaitPointerEvent()
6325 topBoxPointerEventCount++
6326 }
6327 }
6328 }
6329 )
6330 Box(
6331 Modifier.size(40.dp) // Below minimum touch target 48.dp
6332 .background(Color.Cyan)
6333 .pointerInput(Unit) {
6334 awaitPointerEventScope {
6335 while (true) {
6336 awaitPointerEvent()
6337 bottomBoxPointerEventCount++
6338 }
6339 }
6340 }
6341 .onGloballyPositioned {
6342 bottomBoxCoordinates = it
6343 latch.countDown()
6344 }
6345 )
6346 }
6347 }
6348 }
6349 assertTrue(latch.await(1, TimeUnit.SECONDS))
6350 dispatchTouchEvent(ACTION_DOWN, bottomBoxCoordinates!!)
6351 dispatchTouchEvent(ACTION_UP, bottomBoxCoordinates!!)
6352
6353 rule.runOnUiThread {
6354 assertThat(containingColumnPointerEventCount).isEqualTo(2)
6355 assertThat(topBoxPointerEventCount).isEqualTo(0)
6356 assertThat(bottomBoxPointerEventCount).isEqualTo(2)
6357 }
6358
6359 val halfSpacing: Float = spaceBetweenElementsInColumnPixel!! / 2
6360 val negativeHalfSpaceTriggersBottomBox = -halfSpacing
6361 val negativeHalfSpaceMinusOnePixelTriggersTopBox =
6362 negativeHalfSpaceTriggersBottomBox - dpInPixel!!
6363
6364 dispatchTouchEvent(
6365 ACTION_DOWN,
6366 bottomBoxCoordinates!!,
6367 Offset(0f, negativeHalfSpaceTriggersBottomBox)
6368 )
6369 dispatchTouchEvent(
6370 ACTION_UP,
6371 bottomBoxCoordinates!!,
6372 Offset(0f, negativeHalfSpaceTriggersBottomBox)
6373 )
6374
6375 rule.runOnUiThread {
6376 assertThat(containingColumnPointerEventCount).isEqualTo(4)
6377 assertThat(topBoxPointerEventCount).isEqualTo(0)
6378 assertThat(bottomBoxPointerEventCount).isEqualTo(4)
6379 }
6380
6381 dispatchTouchEvent(
6382 ACTION_DOWN,
6383 bottomBoxCoordinates!!,
6384 Offset(0f, negativeHalfSpaceMinusOnePixelTriggersTopBox)
6385 )
6386 dispatchTouchEvent(
6387 ACTION_UP,
6388 bottomBoxCoordinates!!,
6389 Offset(0f, negativeHalfSpaceMinusOnePixelTriggersTopBox)
6390 )
6391
6392 rule.runOnUiThread {
6393 assertThat(containingColumnPointerEventCount).isEqualTo(6)
6394 assertThat(topBoxPointerEventCount).isEqualTo(2)
6395 assertThat(bottomBoxPointerEventCount).isEqualTo(4)
6396 }
6397 }
6398
6399 /**
6400 * Tests overlapping siblings using a containing parent Box. The one on top (and that wins) is
6401 * determined by order.
6402 */
6403 @Test
6404 fun hitOnOverlappingSiblings_rightSiblingAbove_triggersRightSibling() {
6405 var containingBoxPointerEventCount = 0
6406 var leftBoxPointerEventCount = 0
6407 var rightBoxPointerEventCount = 0
6408 var rightBoxInnerChildPointerEventCount = 0
6409
6410 var rightBoxInnerChild: LayoutCoordinates? = null
6411 val latch = CountDownLatch(1)
6412 rule.runOnUiThread {
6413 container.setContent {
6414 Box(
6415 Modifier.size(200.dp).background(Color.Red).pointerInput(Unit) {
6416 awaitPointerEventScope {
6417 while (true) {
6418 awaitPointerEvent()
6419 containingBoxPointerEventCount++
6420 }
6421 }
6422 }
6423 ) {
6424 Box(
6425 Modifier.width(180.dp)
6426 .height(50.dp)
6427 .padding(10.dp)
6428 .background(Color.Green)
6429 .align(Alignment.TopStart)
6430 .pointerInput(Unit) {
6431 awaitPointerEventScope {
6432 while (true) {
6433 awaitPointerEvent()
6434 leftBoxPointerEventCount++
6435 }
6436 }
6437 }
6438 )
6439 Box(
6440 Modifier.size(100.dp)
6441 .background(Color.Blue)
6442 .align(Alignment.TopEnd)
6443 .pointerInput(Unit) {
6444 awaitPointerEventScope {
6445 while (true) {
6446 awaitPointerEvent()
6447 rightBoxPointerEventCount++
6448 }
6449 }
6450 }
6451 ) {
6452 Box(
6453 Modifier.padding(10.dp)
6454 .size(80.dp)
6455 .background(Color.Cyan)
6456 .pointerInput(Unit) {
6457 awaitPointerEventScope {
6458 while (true) {
6459 awaitPointerEvent()
6460 rightBoxInnerChildPointerEventCount++
6461 }
6462 }
6463 }
6464 .onGloballyPositioned {
6465 rightBoxInnerChild = it
6466 latch.countDown()
6467 }
6468 )
6469 }
6470 }
6471 }
6472 }
6473 assertTrue(latch.await(1, TimeUnit.SECONDS))
6474
6475 dispatchTouchEvent(ACTION_DOWN, rightBoxInnerChild!!)
6476 dispatchTouchEvent(ACTION_UP, rightBoxInnerChild!!)
6477
6478 rule.runOnUiThread {
6479 assertThat(containingBoxPointerEventCount).isEqualTo(2)
6480 assertThat(leftBoxPointerEventCount).isEqualTo(0)
6481 assertThat(rightBoxPointerEventCount).isEqualTo(2)
6482 assertThat(rightBoxInnerChildPointerEventCount).isEqualTo(2)
6483 }
6484 }
6485
6486 /**
6487 * This test is exactly the same as
6488 * [hitOnOverlappingSiblings_rightSiblingAbove_triggersRightSibling], but with the z-index
6489 * manually changed on the overlapping sibling so it is above instead of below the other
6490 * conflicting sibling (so left sibling is triggered instead of right sibling).
6491 */
6492 @Test
6493 fun hitOnOverlappingSiblings_leftSiblingAboveViaZIndex_triggersLeftSibling() {
6494 var containingBoxPointerEventCount = 0
6495 var leftBoxPointerEventCount = 0
6496 var rightBoxPointerEventCount = 0
6497 var rightBoxInnerChildPointerEventCount = 0
6498
6499 var rightBoxInnerChild: LayoutCoordinates? = null
6500 val latch = CountDownLatch(1)
6501 rule.runOnUiThread {
6502 container.setContent {
6503 Box(
6504 Modifier.size(200.dp).background(Color.Red).pointerInput(Unit) {
6505 awaitPointerEventScope {
6506 while (true) {
6507 awaitPointerEvent()
6508 containingBoxPointerEventCount++
6509 }
6510 }
6511 }
6512 ) {
6513 Box(
6514 Modifier.width(180.dp)
6515 .height(50.dp)
6516 .padding(10.dp)
6517 .background(Color.Green)
6518 .zIndex(1f)
6519 .align(Alignment.TopStart)
6520 .pointerInput(Unit) {
6521 awaitPointerEventScope {
6522 while (true) {
6523 awaitPointerEvent()
6524 leftBoxPointerEventCount++
6525 }
6526 }
6527 }
6528 )
6529 Box(
6530 Modifier.size(100.dp)
6531 .background(Color.Blue)
6532 .align(Alignment.TopEnd)
6533 .pointerInput(Unit) {
6534 awaitPointerEventScope {
6535 while (true) {
6536 awaitPointerEvent()
6537 rightBoxPointerEventCount++
6538 }
6539 }
6540 }
6541 ) {
6542 Box(
6543 Modifier.padding(10.dp)
6544 .size(80.dp)
6545 .background(Color.Cyan)
6546 .pointerInput(Unit) {
6547 awaitPointerEventScope {
6548 while (true) {
6549 awaitPointerEvent()
6550 rightBoxInnerChildPointerEventCount++
6551 }
6552 }
6553 }
6554 .onGloballyPositioned {
6555 rightBoxInnerChild = it
6556 latch.countDown()
6557 }
6558 )
6559 }
6560 }
6561 }
6562 }
6563 assertTrue(latch.await(1, TimeUnit.SECONDS))
6564
6565 dispatchTouchEvent(ACTION_DOWN, rightBoxInnerChild!!)
6566 dispatchTouchEvent(ACTION_UP, rightBoxInnerChild!!)
6567
6568 rule.runOnUiThread {
6569 assertThat(containingBoxPointerEventCount).isEqualTo(2)
6570 assertThat(leftBoxPointerEventCount).isEqualTo(2)
6571 assertThat(rightBoxPointerEventCount).isEqualTo(0)
6572 assertThat(rightBoxInnerChildPointerEventCount).isEqualTo(0)
6573 }
6574 }
6575
6576 /**
6577 * This test uses the same UI elements as
6578 * [hitOnOverlappingSiblings_rightSiblingAbove_triggersRightSibling], but they are declared in
6579 * reverse order, so we can test the correct overlapping sibling (one above the other is
6580 * triggered).
6581 */
6582 @Test
6583 fun hitOnOverlappingSiblingsReversedUI_rightSiblingAbove_triggersRightSibling() {
6584 var containingBoxPointerEventCount = 0
6585 var leftBoxPointerEventCount = 0
6586 var leftBoxInnerChildPointerEventCount = 0
6587 var rightBoxPointerEventCount = 0
6588
6589 var rightBoxInnerChild: LayoutCoordinates? = null
6590 val latch = CountDownLatch(1)
6591 rule.runOnUiThread {
6592 container.setContent {
6593 Box(
6594 Modifier.size(200.dp).background(Color.Red).pointerInput(Unit) {
6595 awaitPointerEventScope {
6596 while (true) {
6597 awaitPointerEvent()
6598 containingBoxPointerEventCount++
6599 }
6600 }
6601 }
6602 ) {
6603 Box(
6604 Modifier.size(100.dp)
6605 .background(Color.Blue)
6606 .align(Alignment.TopStart)
6607 .pointerInput(Unit) {
6608 awaitPointerEventScope {
6609 while (true) {
6610 awaitPointerEvent()
6611 leftBoxPointerEventCount++
6612 }
6613 }
6614 }
6615 ) {
6616 Box(
6617 Modifier.padding(10.dp)
6618 .size(80.dp)
6619 .background(Color.Cyan)
6620 .pointerInput(Unit) {
6621 awaitPointerEventScope {
6622 while (true) {
6623 awaitPointerEvent()
6624 leftBoxInnerChildPointerEventCount++
6625 }
6626 }
6627 }
6628 .onGloballyPositioned {
6629 rightBoxInnerChild = it
6630 latch.countDown()
6631 }
6632 )
6633 }
6634
6635 Box(
6636 Modifier.width(200.dp)
6637 .height(50.dp)
6638 .padding(10.dp)
6639 .background(Color.Green)
6640 .align(Alignment.TopEnd)
6641 .pointerInput(Unit) {
6642 awaitPointerEventScope {
6643 while (true) {
6644 awaitPointerEvent()
6645 rightBoxPointerEventCount++
6646 }
6647 }
6648 }
6649 )
6650 }
6651 }
6652 }
6653 assertTrue(latch.await(1, TimeUnit.SECONDS))
6654
6655 dispatchTouchEvent(ACTION_DOWN, rightBoxInnerChild!!)
6656 dispatchTouchEvent(ACTION_UP, rightBoxInnerChild!!)
6657
6658 rule.runOnUiThread {
6659 assertThat(containingBoxPointerEventCount).isEqualTo(2)
6660 assertThat(rightBoxPointerEventCount).isEqualTo(2)
6661 assertThat(leftBoxPointerEventCount).isEqualTo(0)
6662 assertThat(leftBoxInnerChildPointerEventCount).isEqualTo(0)
6663 }
6664 }
6665
6666 /**
6667 * This test is exactly the same as
6668 * [hitOnOverlappingSiblingsReversedUI_rightSiblingAbove_triggersRightSibling], but with the
6669 * z-index manually changed on the overlapping sibling so it is above instead of below the other
6670 * conflicting sibling (so left sibling is triggered instead of right sibling).
6671 */
6672 @Test
6673 fun hitOnOverlappingSiblingsReversedUI_leftSiblingAboveViaZIndex_triggersLeftSibling() {
6674 var containingBoxPointerEventCount = 0
6675 var leftBoxPointerEventCount = 0
6676 var leftBoxInnerChildPointerEventCount = 0
6677 var rightBoxPointerEventCount = 0
6678
6679 var rightBoxInnerChild: LayoutCoordinates? = null
6680 val latch = CountDownLatch(1)
6681 rule.runOnUiThread {
6682 container.setContent {
6683 Box(
6684 Modifier.size(200.dp).background(Color.Red).pointerInput(Unit) {
6685 awaitPointerEventScope {
6686 while (true) {
6687 awaitPointerEvent()
6688 containingBoxPointerEventCount++
6689 }
6690 }
6691 }
6692 ) {
6693 Box(
6694 Modifier.size(100.dp)
6695 .background(Color.Blue)
6696 .align(Alignment.TopStart)
6697 .zIndex(1f)
6698 .pointerInput(Unit) {
6699 awaitPointerEventScope {
6700 while (true) {
6701 awaitPointerEvent()
6702 leftBoxPointerEventCount++
6703 }
6704 }
6705 }
6706 ) {
6707 Box(
6708 Modifier.padding(10.dp)
6709 .size(80.dp)
6710 .background(Color.Cyan)
6711 .pointerInput(Unit) {
6712 awaitPointerEventScope {
6713 while (true) {
6714 awaitPointerEvent()
6715 leftBoxInnerChildPointerEventCount++
6716 }
6717 }
6718 }
6719 .onGloballyPositioned {
6720 rightBoxInnerChild = it
6721 latch.countDown()
6722 }
6723 )
6724 }
6725
6726 Box(
6727 Modifier.width(200.dp)
6728 .height(50.dp)
6729 .padding(10.dp)
6730 .background(Color.Green)
6731 .align(Alignment.TopEnd)
6732 .pointerInput(Unit) {
6733 awaitPointerEventScope {
6734 while (true) {
6735 awaitPointerEvent()
6736 rightBoxPointerEventCount++
6737 }
6738 }
6739 }
6740 )
6741 }
6742 }
6743 }
6744 assertTrue(latch.await(1, TimeUnit.SECONDS))
6745
6746 dispatchTouchEvent(ACTION_DOWN, rightBoxInnerChild!!)
6747 dispatchTouchEvent(ACTION_UP, rightBoxInnerChild!!)
6748
6749 rule.runOnUiThread {
6750 assertThat(containingBoxPointerEventCount).isEqualTo(2)
6751 assertThat(rightBoxPointerEventCount).isEqualTo(0)
6752 assertThat(leftBoxPointerEventCount).isEqualTo(2)
6753 assertThat(leftBoxInnerChildPointerEventCount).isEqualTo(2)
6754 }
6755 }
6756
6757 /**
6758 * This is similar to
6759 * [hitOnOverlappingSiblingsReversedUI_leftSiblingAboveViaZIndex_triggersLeftSibling] but
6760 * instead of setting the z-index of a sibling, we are setting the z-index of the child of the
6761 * sibling. Because z-index only influences siblings (and not parents, grandparents, etc.), this
6762 * will not changing the order of the left sibling and the right sibling will still win (since
6763 * it's UI was declared later).
6764 */
6765 @Test
6766 fun hitOnOverlappingSiblingsReversedUI_leftSiblingChildHighZIndex_triggersRightSibling() {
6767 var containingBoxPointerEventCount = 0
6768 var leftBoxPointerEventCount = 0
6769 var leftBoxInnerChildPointerEventCount = 0
6770 var rightBoxPointerEventCount = 0
6771
6772 var rightBoxInnerChild: LayoutCoordinates? = null
6773 val latch = CountDownLatch(1)
6774 rule.runOnUiThread {
6775 container.setContent {
6776 Box(
6777 Modifier.size(200.dp).background(Color.Red).pointerInput(Unit) {
6778 awaitPointerEventScope {
6779 while (true) {
6780 awaitPointerEvent()
6781 containingBoxPointerEventCount++
6782 }
6783 }
6784 }
6785 ) {
6786 Box(
6787 Modifier.size(100.dp)
6788 .background(Color.Blue)
6789 .align(Alignment.TopStart)
6790 .pointerInput(Unit) {
6791 awaitPointerEventScope {
6792 while (true) {
6793 awaitPointerEvent()
6794 leftBoxPointerEventCount++
6795 }
6796 }
6797 }
6798 ) {
6799 Box(
6800 Modifier.padding(10.dp)
6801 .size(80.dp)
6802 .background(Color.Cyan)
6803 .zIndex(1f)
6804 .pointerInput(Unit) {
6805 awaitPointerEventScope {
6806 while (true) {
6807 awaitPointerEvent()
6808 leftBoxInnerChildPointerEventCount++
6809 }
6810 }
6811 }
6812 .onGloballyPositioned {
6813 rightBoxInnerChild = it
6814 latch.countDown()
6815 }
6816 )
6817 }
6818
6819 Box(
6820 Modifier.width(200.dp)
6821 .height(50.dp)
6822 .padding(10.dp)
6823 .background(Color.Green)
6824 .align(Alignment.TopEnd)
6825 .pointerInput(Unit) {
6826 awaitPointerEventScope {
6827 while (true) {
6828 awaitPointerEvent()
6829 rightBoxPointerEventCount++
6830 }
6831 }
6832 }
6833 )
6834 }
6835 }
6836 }
6837 assertTrue(latch.await(1, TimeUnit.SECONDS))
6838
6839 dispatchTouchEvent(ACTION_DOWN, rightBoxInnerChild!!)
6840 dispatchTouchEvent(ACTION_UP, rightBoxInnerChild!!)
6841
6842 rule.runOnUiThread {
6843 assertThat(containingBoxPointerEventCount).isEqualTo(2)
6844 assertThat(rightBoxPointerEventCount).isEqualTo(2)
6845 assertThat(leftBoxPointerEventCount).isEqualTo(0)
6846 assertThat(leftBoxInnerChildPointerEventCount).isEqualTo(0)
6847 }
6848 }
6849
6850 @Test
6851 fun stylusEnterExitPointerArea() {
6852 // Stylus hover enter/exit events should be sent to pointer input areas
6853 val eventLog = mutableListOf<PointerEvent>()
6854 var innerCoordinates: LayoutCoordinates? = null
6855 val latch = CountDownLatch(1)
6856 rule.runOnUiThread {
6857 container.setContent {
6858 Box(
6859 Modifier.fillMaxSize().pointerInput(Unit) {
6860 awaitPointerEventScope {
6861 while (true) {
6862 awaitPointerEvent()
6863 }
6864 }
6865 }
6866 ) {
6867 Box(
6868 Modifier.size(50.dp)
6869 .align(AbsoluteAlignment.BottomRight)
6870 .pointerInput(Unit) {
6871 awaitPointerEventScope {
6872 while (true) {
6873 val event = awaitPointerEvent()
6874 event.changes.forEach { it.consume() }
6875 eventLog += event
6876 }
6877 }
6878 }
6879 .onGloballyPositioned {
6880 innerCoordinates = it
6881 latch.countDown()
6882 }
6883 )
6884 }
6885 }
6886 }
6887 assertTrue(latch.await(1, TimeUnit.SECONDS))
6888
6889 val coords = innerCoordinates!!
6890 val outside = Offset(-100f, -100f)
6891 dispatchStylusEvents(coords, outside, ACTION_HOVER_ENTER)
6892 rule.runOnUiThread {
6893 // The event didn't land inside the box, so it shouldn't get the hover enter
6894 assertThat(eventLog).isEmpty()
6895 }
6896 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_MOVE)
6897 dispatchStylusEvents(coords, Offset.Zero, ACTION_HOVER_EXIT, ACTION_DOWN)
6898 dispatchStylusEvents(coords, outside, ACTION_MOVE)
6899 dispatchStylusEvents(coords, outside, ACTION_UP, ACTION_HOVER_ENTER)
6900 rule.runOnUiThread {
6901 assertThat(eventLog).hasSize(4)
6902 assertThat(eventLog[0].type).isEqualTo(PointerEventType.Enter)
6903 assertThat(eventLog[1].type).isEqualTo(PointerEventType.Press)
6904 assertThat(eventLog[2].type).isEqualTo(PointerEventType.Exit)
6905 assertThat(eventLog[3].type).isEqualTo(PointerEventType.Release)
6906 }
6907 }
6908
6909 @Test
6910 fun restartStreamAfterNotProcessing() {
6911 // Stylus hover enter/exit events should be sent to pointer input areas
6912 val eventLog = mutableListOf<PointerEvent>()
6913 var hitCoordinates: LayoutCoordinates? = null
6914 var missCoordinates: LayoutCoordinates? = null
6915 val latch = CountDownLatch(2)
6916 rule.runOnUiThread {
6917 container.setContent {
6918 Box(Modifier.fillMaxSize()) {
6919 Box(
6920 Modifier.size(50.dp)
6921 .align(AbsoluteAlignment.TopLeft)
6922 .pointerInput(Unit) {
6923 awaitPointerEventScope {
6924 while (true) {
6925 val event = awaitPointerEvent()
6926 event.changes.forEach { it.consume() }
6927 eventLog += event
6928 }
6929 }
6930 }
6931 .onGloballyPositioned {
6932 hitCoordinates = it
6933 latch.countDown()
6934 }
6935 )
6936 Box(
6937 Modifier.size(50.dp)
6938 .align(AbsoluteAlignment.BottomRight)
6939 .onGloballyPositioned {
6940 missCoordinates = it
6941 latch.countDown()
6942 }
6943 )
6944 }
6945 }
6946 }
6947 assertTrue(latch.await(1, TimeUnit.SECONDS))
6948 val miss = missCoordinates!!
6949 val hit = hitCoordinates!!
6950
6951 // This should hit
6952 dispatchTouchEvent(ACTION_DOWN, hit)
6953 dispatchTouchEvent(ACTION_UP, hit)
6954
6955 // This should miss
6956 dispatchTouchEvent(ACTION_DOWN, miss)
6957
6958 // This should hit
6959 dispatchTouchEvent(ACTION_DOWN, hit)
6960
6961 rule.runOnUiThread {
6962 assertThat(eventLog).hasSize(3)
6963 val down1 = eventLog[0]
6964 val up1 = eventLog[1]
6965 val down2 = eventLog[2]
6966 assertThat(down1.changes).hasSize(1)
6967 assertThat(up1.changes).hasSize(1)
6968 assertThat(down2.changes).hasSize(1)
6969
6970 assertThat(down1.type).isEqualTo(PointerEventType.Press)
6971 assertThat(up1.type).isEqualTo(PointerEventType.Release)
6972 assertThat(down2.type).isEqualTo(PointerEventType.Press)
6973
6974 assertThat(up1.changes[0].id).isEqualTo(down1.changes[0].id)
6975 assertThat(down2.changes[0].id.value).isEqualTo(down1.changes[0].id.value + 2)
6976 }
6977 }
6978
6979 @Test
6980 fun pointerEventGetMotionEventAfterDispatch_returnsNull() {
6981 val eventLog = mutableListOf<PointerEvent>()
6982 val latch = CountDownLatch(1)
6983 var layoutCoordinates: LayoutCoordinates? = null
6984 rule.runOnUiThread {
6985 container.setContent {
6986 Box(
6987 Modifier.fillMaxSize()
6988 .pointerInput(Unit) {
6989 awaitPointerEventScope {
6990 val pointerEvent1 = awaitPointerEvent()
6991 assertThat(pointerEvent1.motionEvent).isNotNull()
6992 eventLog.add(pointerEvent1)
6993
6994 val pointerEvent2 = awaitPointerEvent()
6995 assertThat(pointerEvent2.motionEvent).isNotNull()
6996 assertThat(pointerEvent1.motionEvent).isNull()
6997 eventLog.add(pointerEvent2)
6998 }
6999 }
7000 .onGloballyPositioned {
7001 layoutCoordinates = it
7002 latch.countDown()
7003 }
7004 )
7005 }
7006 }
7007 assertTrue(latch.await(1, TimeUnit.SECONDS))
7008
7009 dispatchTouchEvent(action = ACTION_DOWN, layoutCoordinates = layoutCoordinates!!)
7010 dispatchTouchEvent(action = ACTION_UP, layoutCoordinates = layoutCoordinates!!)
7011
7012 rule.waitForFutureFrame()
7013 // Gut check that the pointer events are correctly dispatched.
7014 rule.runOnUiThread { assertThat(eventLog).hasSize(2) }
7015 }
7016
7017 private fun createPointerEventAt(eventTime: Int, action: Int, locationInWindow: IntArray) =
7018 MotionEvent(
7019 eventTime,
7020 action,
7021 1,
7022 0,
7023 arrayOf(PointerProperties(0)),
7024 arrayOf(PointerCoords(locationInWindow[0].toFloat(), locationInWindow[1].toFloat()))
7025 )
7026 }
7027
7028 @Composable
AndroidWithComposenull7029 fun AndroidWithCompose(context: Context, androidPadding: Int, content: @Composable () -> Unit) {
7030 val anotherLayout =
7031 ComposeView(context).also { view ->
7032 view.setContent { content() }
7033 view.setPadding(androidPadding, androidPadding, androidPadding, androidPadding)
7034 }
7035 AndroidView({ anotherLayout })
7036 }
7037
<lambda>null7038 fun Modifier.consumeMovementGestureFilter(consumeMovement: Boolean = false): Modifier = composed {
7039 val filter = remember(consumeMovement) { ConsumeMovementGestureFilter(consumeMovement) }
7040 PointerInputModifierImpl(filter)
7041 }
7042
<lambda>null7043 fun Modifier.consumeDownGestureFilter(onDown: (Offset) -> Unit): Modifier = composed {
7044 val filter = remember { ConsumeDownChangeFilter() }
7045 filter.onDown = onDown
7046 this.then(PointerInputModifierImpl(filter))
7047 }
7048
logEventsGestureFilternull7049 fun Modifier.logEventsGestureFilter(log: MutableList<List<PointerInputChange>>): Modifier =
7050 composed {
7051 val filter = remember { LogEventsGestureFilter(log) }
7052 this.then(PointerInputModifierImpl(filter))
7053 }
7054
7055 private class PointerInputModifierImpl(override val pointerInputFilter: PointerInputFilter) :
7056 PointerInputModifier
7057
7058 private class ConsumeMovementGestureFilter(val consumeMovement: Boolean) : PointerInputFilter() {
onPointerEventnull7059 override fun onPointerEvent(
7060 pointerEvent: PointerEvent,
7061 pass: PointerEventPass,
7062 bounds: IntSize
7063 ) {
7064 if (consumeMovement) {
7065 pointerEvent.changes.fastForEach { it.consume() }
7066 }
7067 }
7068
onCancelnull7069 override fun onCancel() {}
7070 }
7071
7072 private class ConsumeDownChangeFilter : PointerInputFilter() {
<lambda>null7073 var onDown by mutableStateOf<(Offset) -> Unit>({})
7074
onPointerEventnull7075 override fun onPointerEvent(
7076 pointerEvent: PointerEvent,
7077 pass: PointerEventPass,
7078 bounds: IntSize
7079 ) {
7080 pointerEvent.changes.fastForEach {
7081 if (it.changedToDown()) {
7082 onDown(it.position)
7083 it.consume()
7084 }
7085 }
7086 }
7087
onCancelnull7088 override fun onCancel() {}
7089 }
7090
7091 private class LogEventsGestureFilter(val log: MutableList<List<PointerInputChange>>) :
7092 PointerInputFilter() {
7093
onPointerEventnull7094 override fun onPointerEvent(
7095 pointerEvent: PointerEvent,
7096 pass: PointerEventPass,
7097 bounds: IntSize
7098 ) {
7099 if (pass == PointerEventPass.Initial) {
7100 log.add(pointerEvent.changes.map { it.copy() })
7101 }
7102 }
7103
onCancelnull7104 override fun onCancel() {}
7105 }
7106
7107 @Suppress("TestFunctionName")
7108 @Composable
FillLayoutnull7109 private fun FillLayout(modifier: Modifier = Modifier) {
7110 Layout({}, modifier) { _, constraints ->
7111 layout(constraints.maxWidth, constraints.maxHeight) {}
7112 }
7113 }
7114
countDownnull7115 private fun countDown(block: (CountDownLatch) -> Unit) {
7116 val countDownLatch = CountDownLatch(1)
7117 block(countDownLatch)
7118 assertThat(countDownLatch.await(1, TimeUnit.SECONDS)).isTrue()
7119 }
7120
7121 class AndroidPointerInputTestActivity : ComponentActivity()
7122
MotionEventnull7123 private fun MotionEvent(
7124 eventTime: Int,
7125 action: Int,
7126 numPointers: Int,
7127 actionIndex: Int,
7128 pointerProperties: Array<MotionEvent.PointerProperties>,
7129 pointerCoords: Array<MotionEvent.PointerCoords>,
7130 buttonState: Int =
7131 if (
7132 pointerProperties[0].toolType == TOOL_TYPE_MOUSE &&
7133 (action == ACTION_DOWN || action == ACTION_MOVE)
7134 )
7135 MotionEvent.BUTTON_PRIMARY
7136 else 0,
7137 ): MotionEvent {
7138 val source =
7139 if (pointerProperties[0].toolType == TOOL_TYPE_MOUSE) {
7140 InputDevice.SOURCE_MOUSE
7141 } else {
7142 InputDevice.SOURCE_TOUCHSCREEN
7143 }
7144 return MotionEvent.obtain(
7145 0,
7146 eventTime.toLong(),
7147 action + (actionIndex shl ACTION_POINTER_INDEX_SHIFT),
7148 numPointers,
7149 pointerProperties,
7150 pointerCoords,
7151 0,
7152 buttonState,
7153 0f,
7154 0f,
7155 0,
7156 0,
7157 source,
7158 0
7159 )
7160 }
7161
7162 /*
7163 * Version of MotionEvent() that accepts classification.
7164 */
7165 @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
MotionEventnull7166 private fun MotionEvent(
7167 eventTime: Int,
7168 action: Int,
7169 numPointers: Int,
7170 actionIndex: Int,
7171 pointerProperties: Array<MotionEvent.PointerProperties>,
7172 pointerCoords: Array<MotionEvent.PointerCoords>,
7173 buttonState: Int =
7174 if (
7175 pointerProperties[0].toolType == TOOL_TYPE_MOUSE &&
7176 (action == ACTION_DOWN || action == ACTION_MOVE)
7177 )
7178 MotionEvent.BUTTON_PRIMARY
7179 else 0,
7180 classification: Int
7181 ): MotionEvent {
7182 val source =
7183 if (pointerProperties[0].toolType == TOOL_TYPE_MOUSE) {
7184 InputDevice.SOURCE_MOUSE
7185 } else {
7186 InputDevice.SOURCE_TOUCHSCREEN
7187 }
7188 return MotionEvent.obtain(
7189 0,
7190 eventTime.toLong(),
7191 action + (actionIndex shl ACTION_POINTER_INDEX_SHIFT),
7192 numPointers,
7193 pointerProperties,
7194 pointerCoords,
7195 0,
7196 buttonState,
7197 0f,
7198 0f,
7199 0,
7200 0,
7201 source,
7202 0,
7203 0,
7204 classification
7205 )!!
7206 }
7207
findRootViewnull7208 internal fun findRootView(view: View): View {
7209 val parent = view.parent
7210 if (parent is View) {
7211 return findRootView(parent)
7212 }
7213 return view
7214 }
7215