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