1 /*
<lambda>null2  * Copyright 2023 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.viewinterop
18 
19 import android.content.Context
20 import android.util.AttributeSet
21 import android.view.MotionEvent
22 import android.view.VelocityTracker
23 import android.view.View
24 import androidx.activity.ComponentActivity
25 import androidx.annotation.LayoutRes
26 import androidx.compose.foundation.gestures.Orientation
27 import androidx.compose.foundation.gestures.draggable
28 import androidx.compose.foundation.gestures.draggable2D
29 import androidx.compose.foundation.gestures.rememberDraggable2DState
30 import androidx.compose.foundation.gestures.rememberDraggableState
31 import androidx.compose.foundation.layout.Box
32 import androidx.compose.foundation.layout.fillMaxSize
33 import androidx.compose.runtime.Composable
34 import androidx.compose.runtime.CompositionLocalProvider
35 import androidx.compose.ui.ExperimentalComposeUiApi
36 import androidx.compose.ui.Modifier
37 import androidx.compose.ui.background
38 import androidx.compose.ui.graphics.Color
39 import androidx.compose.ui.input.pointer.AwaitPointerEventScope
40 import androidx.compose.ui.input.pointer.PointerId
41 import androidx.compose.ui.input.pointer.PointerInputChange
42 import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
43 import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
44 import androidx.compose.ui.platform.ComposeView
45 import androidx.compose.ui.platform.LocalViewConfiguration
46 import androidx.compose.ui.platform.ViewConfiguration
47 import androidx.compose.ui.test.junit4.createAndroidComposeRule
48 import androidx.compose.ui.tests.R
49 import androidx.compose.ui.unit.Velocity
50 import androidx.compose.ui.util.fastFirstOrNull
51 import androidx.lifecycle.Lifecycle
52 import androidx.test.core.app.ActivityScenario
53 import androidx.test.espresso.Espresso
54 import androidx.test.espresso.UiController
55 import androidx.test.espresso.action.CoordinatesProvider
56 import androidx.test.espresso.action.GeneralLocation
57 import androidx.test.espresso.action.GeneralSwipeAction
58 import androidx.test.espresso.action.MotionEvents
59 import androidx.test.espresso.action.Press
60 import androidx.test.espresso.action.Swiper
61 import androidx.test.espresso.matcher.ViewMatchers.withId
62 import androidx.test.ext.junit.runners.AndroidJUnit4
63 import androidx.test.filters.MediumTest
64 import com.google.common.truth.Truth.assertThat
65 import com.google.errorprone.annotations.CanIgnoreReturnValue
66 import kotlin.math.absoluteValue
67 import kotlin.test.assertTrue
68 import org.junit.Before
69 import org.junit.Rule
70 import org.junit.Test
71 import org.junit.runner.RunWith
72 
73 @OptIn(ExperimentalComposeUiApi::class)
74 @MediumTest
75 @RunWith(AndroidJUnit4::class)
76 class VelocityTrackingParityTest {
77 
78     @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
79 
80     private val draggableView: VelocityTrackingView
81         get() = rule.activity.findViewById(R.id.draggable_view)
82 
83     private val composeView: ComposeView
84         get() = rule.activity.findViewById(R.id.compose_view)
85 
86     private var latestComposeVelocity = Velocity.Zero
87 
88     @Before
89     fun setUp() {
90         latestComposeVelocity = Velocity.Zero
91         VelocityTrackerAddPointsFix = true
92     }
93 
94     fun tearDown() {
95         draggableView.tearDown()
96     }
97 
98     @Test
99     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallVeryFast() {
100         // Arrange
101         createActivity()
102         checkVisibility(composeView, View.GONE)
103         checkVisibility(draggableView, View.VISIBLE)
104 
105         // Act: Use system to send motion events and collect them.
106         smallGestureVeryFast(R.id.draggable_view)
107 
108         val latestVelocityInViewX = draggableView.latestVelocity.x
109         val latestVelocityInViewY = draggableView.latestVelocity.y
110 
111         // switch visibility
112         rule.runOnUiThread {
113             composeView.visibility = View.VISIBLE
114             draggableView.visibility = View.GONE
115         }
116 
117         checkVisibility(composeView, View.VISIBLE)
118         checkVisibility(draggableView, View.GONE)
119 
120         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
121 
122         // Inject the same events in compose view
123         for (event in draggableView.motionEvents) {
124             composeView.dispatchTouchEvent(event)
125         }
126 
127         // assert
128         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
129         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
130     }
131 
132     @Test
133     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallFast() {
134         // Arrange
135         createActivity()
136         checkVisibility(composeView, View.GONE)
137         checkVisibility(draggableView, View.VISIBLE)
138 
139         // Act: Use system to send motion events and collect them.
140         smallGestureFast(R.id.draggable_view)
141 
142         val latestVelocityInViewX = draggableView.latestVelocity.x
143         val latestVelocityInViewY = draggableView.latestVelocity.y
144 
145         // switch visibility
146         rule.runOnUiThread {
147             composeView.visibility = View.VISIBLE
148             draggableView.visibility = View.GONE
149         }
150 
151         rule.waitForIdle()
152 
153         checkVisibility(composeView, View.VISIBLE)
154         checkVisibility(draggableView, View.GONE)
155 
156         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
157 
158         // Inject the same events in compose view
159         for (event in draggableView.motionEvents) {
160             composeView.dispatchTouchEvent(event)
161         }
162 
163         // assert
164         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
165         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
166     }
167 
168     @Test
169     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallSlow() {
170         // Arrange
171         createActivity()
172         checkVisibility(composeView, View.GONE)
173         checkVisibility(draggableView, View.VISIBLE)
174 
175         // Act: Use system to send motion events and collect them.
176         smallGestureSlow(R.id.draggable_view)
177 
178         val latestVelocityInViewX = draggableView.latestVelocity.x
179         val latestVelocityInViewY = draggableView.latestVelocity.y
180 
181         // switch visibility
182         rule.runOnUiThread {
183             composeView.visibility = View.VISIBLE
184             draggableView.visibility = View.GONE
185         }
186 
187         checkVisibility(composeView, View.VISIBLE)
188         checkVisibility(draggableView, View.GONE)
189 
190         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
191 
192         // Inject the same events in compose view
193         for (event in draggableView.motionEvents) {
194             composeView.dispatchTouchEvent(event)
195         }
196         // assert
197         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
198         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
199     }
200 
201     @Test
202     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_largeFast() {
203         // Arrange
204         createActivity()
205         checkVisibility(composeView, View.GONE)
206         checkVisibility(draggableView, View.VISIBLE)
207 
208         // Act: Use system to send motion events and collect them.
209         largeGestureFast(R.id.draggable_view)
210 
211         val latestVelocityInViewX = draggableView.latestVelocity.x
212         val latestVelocityInViewY = draggableView.latestVelocity.y
213 
214         // switch visibility
215         rule.runOnUiThread {
216             composeView.visibility = View.VISIBLE
217             draggableView.visibility = View.GONE
218         }
219 
220         rule.waitForIdle()
221 
222         checkVisibility(composeView, View.VISIBLE)
223         checkVisibility(draggableView, View.GONE)
224 
225         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
226 
227         // Inject the same events in compose view
228         for (event in draggableView.motionEvents) {
229             composeView.dispatchTouchEvent(event)
230         }
231 
232         // assert
233         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
234         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
235     }
236 
237     @Test
238     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_largeVeryFast() {
239         // Arrange
240         createActivity()
241         checkVisibility(composeView, View.GONE)
242         checkVisibility(draggableView, View.VISIBLE)
243 
244         // Act: Use system to send motion events and collect them.
245         largeGestureVeryFast(R.id.draggable_view)
246 
247         val latestVelocityInViewX = draggableView.latestVelocity.x
248         val latestVelocityInViewY = draggableView.latestVelocity.y
249 
250         // switch visibility
251         rule.runOnUiThread {
252             composeView.visibility = View.VISIBLE
253             draggableView.visibility = View.GONE
254         }
255 
256         checkVisibility(composeView, View.VISIBLE)
257         checkVisibility(draggableView, View.GONE)
258 
259         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
260 
261         // Inject the same events in compose view
262         for (event in draggableView.motionEvents) {
263             composeView.dispatchTouchEvent(event)
264         }
265 
266         // assert
267         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
268         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
269     }
270 
271     @Test
272     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_orthogonal() {
273         // Arrange
274         createActivity(true)
275         checkVisibility(composeView, View.GONE)
276         checkVisibility(draggableView, View.VISIBLE)
277 
278         // Act: Use system to send motion events and collect them.
279         orthogonalGesture(R.id.draggable_view)
280 
281         val latestVelocityInViewX = draggableView.latestVelocity.x
282         val latestVelocityInViewY = draggableView.latestVelocity.y
283 
284         // switch visibility
285         rule.runOnUiThread {
286             composeView.visibility = View.VISIBLE
287             draggableView.visibility = View.GONE
288         }
289 
290         checkVisibility(composeView, View.VISIBLE)
291         checkVisibility(draggableView, View.GONE)
292 
293         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
294 
295         // Inject the same events in compose view
296         for (event in draggableView.motionEvents) {
297             composeView.dispatchTouchEvent(event)
298         }
299 
300         // assert
301         assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX)
302         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
303     }
304 
305     @Test
306     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_regularSituationOne() {
307         // Arrange
308         createActivity()
309         checkVisibility(composeView, View.GONE)
310         checkVisibility(draggableView, View.VISIBLE)
311 
312         // Act: Use system to send motion events and collect them.
313         regularGestureOne(R.id.draggable_view)
314 
315         val latestVelocityInViewY = draggableView.latestVelocity.y
316 
317         // switch visibility
318         rule.runOnUiThread {
319             composeView.visibility = View.VISIBLE
320             draggableView.visibility = View.GONE
321         }
322 
323         checkVisibility(composeView, View.VISIBLE)
324         checkVisibility(draggableView, View.GONE)
325 
326         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
327 
328         // Inject the same events in compose view
329         for (event in draggableView.motionEvents) {
330             composeView.dispatchTouchEvent(event)
331         }
332 
333         // assert
334         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
335     }
336 
337     @Test
338     fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_regularSituationTwo() {
339         // Arrange
340         createActivity()
341         checkVisibility(composeView, View.GONE)
342         checkVisibility(draggableView, View.VISIBLE)
343 
344         // Act: Use system to send motion events and collect them.
345         regularGestureTwo(R.id.draggable_view)
346 
347         val latestVelocityInViewY = draggableView.latestVelocity.y
348 
349         // switch visibility
350         rule.runOnUiThread {
351             composeView.visibility = View.VISIBLE
352             draggableView.visibility = View.GONE
353         }
354 
355         rule.waitForIdle()
356 
357         checkVisibility(composeView, View.VISIBLE)
358         checkVisibility(draggableView, View.GONE)
359 
360         assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) }
361 
362         // Inject the same events in compose view
363         for (event in draggableView.motionEvents) {
364             composeView.dispatchTouchEvent(event)
365         }
366 
367         // assert
368         assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY)
369     }
370 
371     private fun createActivity(twoDimensional: Boolean = false) {
372         rule.activityRule.scenario.createActivityWithComposeContent(
373             R.layout.velocity_tracker_compose_vs_view
374         ) {
375             TestComposeDraggable(twoDimensional) { latestComposeVelocity = it }
376         }
377     }
378 
379     private fun checkVisibility(view: View, visibility: Int) = assertTrue {
380         view.visibility == visibility
381     }
382 
383     private fun assertIsWithinTolerance(composeVelocity: Float, viewVelocity: Float) {
384         if (composeVelocity.absoluteValue > 1f && viewVelocity.absoluteValue > 1f) {
385             val tolerance = VelocityDifferenceTolerance * kotlin.math.abs(viewVelocity)
386             assertThat(composeVelocity).isWithin(tolerance).of(viewVelocity)
387         } else {
388             assertThat(composeVelocity.toInt()).isEqualTo(viewVelocity.toInt())
389         }
390     }
391 }
392 
smallGestureVeryFastnull393 internal fun smallGestureVeryFast(id: Int) {
394     Espresso.onView(withId(id))
395         .perform(
396             espressoSwipe(
397                 SwiperWithTime(15),
398                 GeneralLocation.CENTER,
399                 GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f)
400             )
401         )
402 }
403 
smallGestureFastnull404 internal fun smallGestureFast(id: Int) {
405     Espresso.onView(withId(id))
406         .perform(
407             espressoSwipe(
408                 SwiperWithTime(25),
409                 GeneralLocation.CENTER,
410                 GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f)
411             )
412         )
413 }
414 
smallGestureSlownull415 internal fun smallGestureSlow(id: Int) {
416     Espresso.onView(withId(id))
417         .perform(
418             espressoSwipe(
419                 SwiperWithTime(200),
420                 GeneralLocation.CENTER,
421                 GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f)
422             )
423         )
424 }
425 
largeGestureFastnull426 internal fun largeGestureFast(id: Int) {
427     Espresso.onView(withId(id))
428         .perform(
429             espressoSwipe(
430                 SwiperWithTime(25),
431                 GeneralLocation.CENTER,
432                 GeneralLocation.translate(GeneralLocation.CENTER, 0f, -500f)
433             )
434         )
435 }
436 
largeGestureVeryFastnull437 internal fun largeGestureVeryFast(id: Int) {
438     Espresso.onView(withId(id))
439         .perform(
440             espressoSwipe(
441                 SwiperWithTime(15),
442                 GeneralLocation.CENTER,
443                 GeneralLocation.translate(GeneralLocation.CENTER, 0f, -500f)
444             )
445         )
446 }
447 
orthogonalGesturenull448 internal fun orthogonalGesture(id: Int) {
449     Espresso.onView(withId(id))
450         .perform(
451             espressoSwipe(
452                 SwiperWithTime(50),
453                 GeneralLocation.CENTER,
454                 GeneralLocation.translate(GeneralLocation.CENTER, -200f, -200f)
455             )
456         )
457 }
458 
regularGestureOnenull459 internal fun regularGestureOne(id: Int) {
460     Espresso.onView(withId(id))
461         .perform(
462             espressoSwipe(
463                 SwiperWithTime(100),
464                 GeneralLocation.CENTER,
465                 GeneralLocation.BOTTOM_CENTER
466             )
467         )
468 }
469 
regularGestureTwonull470 internal fun regularGestureTwo(id: Int) {
471     Espresso.onView(withId(id))
472         .perform(
473             espressoSwipe(SwiperWithTime(70), GeneralLocation.CENTER, GeneralLocation.TOP_CENTER)
474         )
475 }
476 
espressoSwipenull477 private fun espressoSwipe(
478     swiper: Swiper,
479     start: CoordinatesProvider,
480     end: CoordinatesProvider
481 ): GeneralSwipeAction {
482     return GeneralSwipeAction(swiper, start, end, Press.FINGER)
483 }
484 
485 @Composable
TestComposeDraggablenull486 fun TestComposeDraggable(
487     twoDimensional: Boolean = false,
488     onDragStopped: (velocity: Velocity) -> Unit
489 ) {
490     val viewConfiguration =
491         object : ViewConfiguration by LocalViewConfiguration.current {
492             override val maximumFlingVelocity: Float
493                 get() = Float.MAX_VALUE // unlimited
494         }
495     CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
496         Box(
497             Modifier.fillMaxSize()
498                 .background(Color.Black)
499                 .then(
500                     if (twoDimensional) {
501                         Modifier.draggable2D(
502                             rememberDraggable2DState {},
503                             onDragStopped = onDragStopped
504                         )
505                     } else {
506                         Modifier.draggable(
507                             rememberDraggableState(onDelta = {}),
508                             onDragStopped = { onDragStopped.invoke(Velocity(0.0f, it)) },
509                             orientation = Orientation.Vertical
510                         )
511                     }
512                 )
513         )
514     }
515 }
516 
ActivityScenarionull517 private fun ActivityScenario<*>.createActivityWithComposeContent(
518     @LayoutRes layout: Int,
519     content: @Composable () -> Unit,
520 ) {
521     onActivity { activity ->
522         activity.setTheme(R.style.Theme_MaterialComponents_Light)
523         activity.setContentView(layout)
524         with(activity.findViewById<ComposeView>(R.id.compose_view)) {
525             setContent(content)
526             visibility = View.GONE
527         }
528 
529         activity.findViewById<VelocityTrackingView>(R.id.draggable_view)?.visibility = View.VISIBLE
530     }
531     moveToState(Lifecycle.State.RESUMED)
532 }
533 
534 /** A view that adds data to a VelocityTracker. */
535 private class VelocityTrackingView(context: Context, attributeSet: AttributeSet) :
536     View(context, attributeSet) {
537     private val tracker = VelocityTracker.obtain()
538     var latestVelocity: Velocity = Velocity.Zero
539     val motionEvents = mutableListOf<MotionEvent?>()
540 
onTouchEventnull541     override fun onTouchEvent(event: MotionEvent?): Boolean {
542         motionEvents.add(MotionEvent.obtain(event))
543         when (event?.action) {
544             MotionEvent.ACTION_UP -> {
545                 tracker.computeCurrentVelocity(1000)
546                 latestVelocity = Velocity(tracker.xVelocity, tracker.yVelocity)
547                 tracker.clear()
548             }
549             MotionEvent.ACTION_DOWN,
550             MotionEvent.ACTION_MOVE -> tracker.addMovement(event)
551             else -> {
552                 tracker.clear()
553                 latestVelocity = Velocity.Zero
554             }
555         }
556         return true
557     }
558 
tearDownnull559     fun tearDown() {
560         tracker.recycle()
561     }
562 }
563 
564 /** Checks the contents of [events] represents a swipe gesture. */
isValidGesturenull565 internal fun isValidGesture(events: List<MotionEvent>): Boolean {
566     val down = events.filter { it.action == MotionEvent.ACTION_DOWN }
567     val move = events.filter { it.action == MotionEvent.ACTION_MOVE }
568     val up = events.filter { it.action == MotionEvent.ACTION_UP }
569     return down.size == 1 && move.isNotEmpty() && up.size == 1
570 }
571 
572 // 5% tolerance
573 private const val VelocityDifferenceTolerance = 0.05f
574 
575 /** Copied from androidx.test.espresso.action.Swipe */
576 internal data class SwiperWithTime(val gestureDurationMs: Int) : Swiper {
sendSwipenull577     override fun sendSwipe(
578         uiController: UiController,
579         startCoordinates: FloatArray,
580         endCoordinates: FloatArray,
581         precision: FloatArray
582     ): Swiper.Status {
583         return sendLinearSwipe(
584             uiController,
585             startCoordinates,
586             endCoordinates,
587             precision,
588             gestureDurationMs
589         )
590     }
591 
checkElementIndexnull592     private fun checkElementIndex(index: Int, size: Int): Int {
593         return checkElementIndex(index, size, "index")
594     }
595 
596     @CanIgnoreReturnValue
checkElementIndexnull597     private fun checkElementIndex(index: Int, size: Int, desc: String): Int {
598         // Carefully optimized for execution by hotspot (explanatory comment above)
599         if (index < 0 || index >= size) {
600             throw IndexOutOfBoundsException(badElementIndex(index, size, desc))
601         }
602         return index
603     }
604 
badElementIndexnull605     private fun badElementIndex(index: Int, size: Int, desc: String): String {
606         return if (index < 0) {
607             String.format("%s (%s) must not be negative", desc, index)
608         } else if (size < 0) {
609             throw IllegalArgumentException("negative size: $size")
610         } else { // index >= size
611             String.format("%s (%s) must be less than size (%s)", desc, index, size)
612         }
613     }
614 
interpolatenull615     private fun interpolate(start: FloatArray, end: FloatArray, steps: Int): Array<FloatArray> {
616         checkElementIndex(1, start.size)
617         checkElementIndex(1, end.size)
618         val res = Array(steps) { FloatArray(2) }
619         for (i in 1 until steps + 1) {
620             res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (steps + 2f)
621             res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (steps + 2f)
622         }
623         return res
624     }
625 
sendLinearSwipenull626     private fun sendLinearSwipe(
627         uiController: UiController,
628         startCoordinates: FloatArray,
629         endCoordinates: FloatArray,
630         precision: FloatArray,
631         duration: Int
632     ): Swiper.Status {
633         val steps = interpolate(startCoordinates, endCoordinates, 10)
634         val events: MutableList<MotionEvent> = ArrayList()
635         val downEvent = MotionEvents.obtainDownEvent(startCoordinates, precision)
636         events.add(downEvent)
637         try {
638             val intervalMS = (duration / steps.size).toLong()
639             var eventTime = downEvent.downTime
640             for (step in steps) {
641                 eventTime += intervalMS
642                 events.add(MotionEvents.obtainMovement(downEvent, eventTime, step))
643             }
644             eventTime += intervalMS
645             events.add(MotionEvents.obtainUpEvent(downEvent, eventTime, endCoordinates))
646             uiController.injectMotionEventSequence(events)
647         } catch (e: Exception) {
648             return Swiper.Status.FAILURE
649         } finally {
650             for (event in events) {
651                 event.recycle()
652             }
653         }
654         return Swiper.Status.SUCCESS
655     }
656 }
657 
awaitDragOrUpnull658 private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
659     pointerId: PointerId,
660     hasDragged: (PointerInputChange) -> Boolean
661 ): PointerInputChange? {
662     var pointer = pointerId
663     while (true) {
664         val event = awaitPointerEvent()
665         val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
666         if (dragEvent.changedToUpIgnoreConsumed()) {
667             val otherDown = event.changes.fastFirstOrNull { it.pressed }
668             if (otherDown == null) {
669                 // This is the last "up"
670                 return dragEvent
671             } else {
672                 pointer = otherDown.id
673             }
674         } else if (hasDragged(dragEvent)) {
675             return dragEvent
676         }
677     }
678 }
679