1 /*
<lambda>null2  * Copyright (C) 2024 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 com.android.compose.animation.scene
18 
19 import androidx.compose.foundation.gestures.Orientation
20 import androidx.compose.foundation.gestures.rememberScrollableState
21 import androidx.compose.foundation.gestures.scrollable
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.foundation.layout.fillMaxSize
24 import androidx.compose.foundation.layout.size
25 import androidx.compose.runtime.getValue
26 import androidx.compose.runtime.mutableStateOf
27 import androidx.compose.runtime.setValue
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.geometry.Offset
30 import androidx.compose.ui.geometry.Size
31 import androidx.compose.ui.input.pointer.AwaitPointerEventScope
32 import androidx.compose.ui.input.pointer.PointerEventPass
33 import androidx.compose.ui.input.pointer.PointerInputChange
34 import androidx.compose.ui.input.pointer.pointerInput
35 import androidx.compose.ui.platform.LocalDensity
36 import androidx.compose.ui.platform.LocalViewConfiguration
37 import androidx.compose.ui.test.junit4.createComposeRule
38 import androidx.compose.ui.test.onRoot
39 import androidx.compose.ui.test.performTouchInput
40 import androidx.test.ext.junit.runners.AndroidJUnit4
41 import com.google.common.truth.Truth.assertThat
42 import kotlinx.coroutines.coroutineScope
43 import kotlinx.coroutines.isActive
44 import org.junit.Rule
45 import org.junit.Test
46 import org.junit.runner.RunWith
47 
48 @RunWith(AndroidJUnit4::class)
49 class MultiPointerDraggableTest {
50     @get:Rule val rule = createComposeRule()
51 
52     @Test
53     fun cancellingPointerCallsOnDragStopped() {
54         val size = 200f
55         val middle = Offset(size / 2f, size / 2f)
56 
57         var enabled by mutableStateOf(false)
58         var started = false
59         var dragged = false
60         var stopped = false
61 
62         var touchSlop = 0f
63         rule.setContent {
64             touchSlop = LocalViewConfiguration.current.touchSlop
65             Box(
66                 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
67                     .multiPointerDraggable(
68                         orientation = Orientation.Vertical,
69                         enabled = { enabled },
70                         startDragImmediately = { false },
71                         onDragStarted = { _, _, _ ->
72                             started = true
73                             object : DragController {
74                                 override fun onDrag(delta: Float) {
75                                     dragged = true
76                                 }
77 
78                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {
79                                     stopped = true
80                                 }
81                             }
82                         },
83                     )
84             )
85         }
86 
87         fun startDraggingDown() {
88             rule.onRoot().performTouchInput {
89                 down(middle)
90                 moveBy(Offset(0f, touchSlop))
91             }
92         }
93 
94         fun releaseFinger() {
95             rule.onRoot().performTouchInput { up() }
96         }
97 
98         // Swiping down does nothing because enabled is false.
99         startDraggingDown()
100         assertThat(started).isFalse()
101         assertThat(dragged).isFalse()
102         assertThat(stopped).isFalse()
103         releaseFinger()
104 
105         // Enable the draggable and swipe down. This should both call onDragStarted() and
106         // onDragDelta().
107         enabled = true
108         rule.waitForIdle()
109         startDraggingDown()
110         assertThat(started).isTrue()
111         assertThat(dragged).isTrue()
112         assertThat(stopped).isFalse()
113 
114         // Disable the pointer input. This should call onDragStopped() even if didn't release the
115         // finger yet.
116         enabled = false
117         rule.waitForIdle()
118         assertThat(started).isTrue()
119         assertThat(dragged).isTrue()
120         assertThat(stopped).isTrue()
121     }
122 
123     @Test
124     fun handleDisappearingScrollableDuringAGesture() {
125         val size = 200f
126         val middle = Offset(size / 2f, size / 2f)
127 
128         var started = false
129         var dragged = false
130         var stopped = false
131         var consumedByScroll = false
132         var hasScrollable by mutableStateOf(true)
133 
134         var touchSlop = 0f
135         rule.setContent {
136             touchSlop = LocalViewConfiguration.current.touchSlop
137             Box(
138                 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
139                     .multiPointerDraggable(
140                         orientation = Orientation.Vertical,
141                         enabled = { true },
142                         startDragImmediately = { false },
143                         onDragStarted = { _, _, _ ->
144                             started = true
145                             object : DragController {
146                                 override fun onDrag(delta: Float) {
147                                     dragged = true
148                                 }
149 
150                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {
151                                     stopped = true
152                                 }
153                             }
154                         },
155                     )
156             ) {
157                 if (hasScrollable) {
158                     Box(
159                         Modifier.scrollable(
160                                 // Consume all the vertical scroll gestures
161                                 rememberScrollableState(
162                                     consumeScrollDelta = {
163                                         consumedByScroll = true
164                                         it
165                                     }
166                                 ),
167                                 Orientation.Vertical
168                             )
169                             .fillMaxSize()
170                     )
171                 }
172             }
173         }
174 
175         fun startDraggingDown() {
176             rule.onRoot().performTouchInput {
177                 down(middle)
178                 moveBy(Offset(0f, touchSlop))
179             }
180         }
181 
182         fun continueDraggingDown() {
183             rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
184         }
185 
186         fun releaseFinger() {
187             rule.onRoot().performTouchInput { up() }
188         }
189 
190         // Swipe down. This should intercepted by the scrollable modifier.
191         startDraggingDown()
192         assertThat(consumedByScroll).isTrue()
193         assertThat(started).isFalse()
194         assertThat(dragged).isFalse()
195         assertThat(stopped).isFalse()
196 
197         // Reset the scroll state for the test
198         consumedByScroll = false
199 
200         // Suddenly remove the scrollable container
201         hasScrollable = false
202         rule.waitForIdle()
203 
204         // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
205         // before consuming it.
206         continueDraggingDown()
207         assertThat(consumedByScroll).isFalse()
208         assertThat(started).isFalse()
209         assertThat(dragged).isFalse()
210         assertThat(stopped).isFalse()
211 
212         // Swipe down. This should both call onDragStarted() and onDragDelta().
213         continueDraggingDown()
214         assertThat(consumedByScroll).isFalse()
215         assertThat(started).isTrue()
216         assertThat(dragged).isTrue()
217         assertThat(stopped).isFalse()
218 
219         rule.waitForIdle()
220         releaseFinger()
221         assertThat(stopped).isTrue()
222     }
223 
224     @Test
225     fun multiPointerWaitAConsumableEventInMainPass() {
226         val size = 200f
227         val middle = Offset(size / 2f, size / 2f)
228 
229         var started = false
230         var dragged = false
231         var stopped = false
232 
233         var childConsumesOnPass: PointerEventPass? = null
234 
235         suspend fun AwaitPointerEventScope.childPointerInputScope() {
236             awaitPointerEvent(PointerEventPass.Initial).also { initial ->
237                 // Check unconsumed: it should be always true
238                 assertThat(initial.changes.any { it.isConsumed }).isFalse()
239 
240                 if (childConsumesOnPass == PointerEventPass.Initial) {
241                     initial.changes.first().consume()
242                 }
243             }
244 
245             awaitPointerEvent(PointerEventPass.Main).also { main ->
246                 // Check unconsumed
247                 if (childConsumesOnPass != PointerEventPass.Initial) {
248                     assertThat(main.changes.any { it.isConsumed }).isFalse()
249                 }
250 
251                 if (childConsumesOnPass == PointerEventPass.Main) {
252                     main.changes.first().consume()
253                 }
254             }
255         }
256 
257         var touchSlop = 0f
258         rule.setContent {
259             touchSlop = LocalViewConfiguration.current.touchSlop
260             Box(
261                 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
262                     .multiPointerDraggable(
263                         orientation = Orientation.Vertical,
264                         enabled = { true },
265                         startDragImmediately = { false },
266                         onDragStarted = { _, _, _ ->
267                             started = true
268                             object : DragController {
269                                 override fun onDrag(delta: Float) {
270                                     dragged = true
271                                 }
272 
273                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {
274                                     stopped = true
275                                 }
276                             }
277                         },
278                     )
279             ) {
280                 Box(
281                     Modifier.pointerInput(Unit) {
282                             coroutineScope {
283                                 awaitPointerEventScope {
284                                     while (isActive) {
285                                         childPointerInputScope()
286                                     }
287                                 }
288                             }
289                         }
290                         .fillMaxSize()
291                 )
292             }
293         }
294 
295         fun startDraggingDown() {
296             rule.onRoot().performTouchInput {
297                 down(middle)
298                 moveBy(Offset(0f, touchSlop))
299             }
300         }
301 
302         fun continueDraggingDown() {
303             rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
304         }
305 
306         childConsumesOnPass = PointerEventPass.Initial
307 
308         startDraggingDown()
309         assertThat(started).isFalse()
310         assertThat(dragged).isFalse()
311         assertThat(stopped).isFalse()
312 
313         continueDraggingDown()
314         assertThat(started).isFalse()
315         assertThat(dragged).isFalse()
316         assertThat(stopped).isFalse()
317 
318         childConsumesOnPass = PointerEventPass.Main
319 
320         continueDraggingDown()
321         assertThat(started).isFalse()
322         assertThat(dragged).isFalse()
323         assertThat(stopped).isFalse()
324 
325         continueDraggingDown()
326         assertThat(started).isFalse()
327         assertThat(dragged).isFalse()
328         assertThat(stopped).isFalse()
329 
330         childConsumesOnPass = null
331 
332         // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
333         // before consuming it.
334         continueDraggingDown()
335         assertThat(started).isFalse()
336         assertThat(dragged).isFalse()
337         assertThat(stopped).isFalse()
338 
339         // Swipe down. This should both call onDragStarted() and onDragDelta().
340         continueDraggingDown()
341         assertThat(started).isTrue()
342         assertThat(dragged).isTrue()
343         assertThat(stopped).isFalse()
344 
345         childConsumesOnPass = PointerEventPass.Main
346 
347         continueDraggingDown()
348         assertThat(stopped).isTrue()
349     }
350 
351     @Test
352     fun multiPointerDuringAnotherGestureWaitAConsumableEventAfterMainPass() {
353         val size = 200f
354         val middle = Offset(size / 2f, size / 2f)
355 
356         var verticalStarted = false
357         var verticalDragged = false
358         var verticalStopped = false
359         var horizontalStarted = false
360         var horizontalDragged = false
361         var horizontalStopped = false
362 
363         var touchSlop = 0f
364         rule.setContent {
365             touchSlop = LocalViewConfiguration.current.touchSlop
366             Box(
367                 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
368                     .multiPointerDraggable(
369                         orientation = Orientation.Vertical,
370                         enabled = { true },
371                         startDragImmediately = { false },
372                         onDragStarted = { _, _, _ ->
373                             verticalStarted = true
374                             object : DragController {
375                                 override fun onDrag(delta: Float) {
376                                     verticalDragged = true
377                                 }
378 
379                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {
380                                     verticalStopped = true
381                                 }
382                             }
383                         },
384                     )
385                     .multiPointerDraggable(
386                         orientation = Orientation.Horizontal,
387                         enabled = { true },
388                         startDragImmediately = { false },
389                         onDragStarted = { _, _, _ ->
390                             horizontalStarted = true
391                             object : DragController {
392                                 override fun onDrag(delta: Float) {
393                                     horizontalDragged = true
394                                 }
395 
396                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {
397                                     horizontalStopped = true
398                                 }
399                             }
400                         },
401                     )
402             )
403         }
404 
405         fun startDraggingDown() {
406             rule.onRoot().performTouchInput {
407                 down(middle)
408                 moveBy(Offset(0f, touchSlop))
409             }
410         }
411 
412         fun startDraggingRight() {
413             rule.onRoot().performTouchInput {
414                 down(middle)
415                 moveBy(Offset(touchSlop, 0f))
416             }
417         }
418 
419         fun stopDragging() {
420             rule.onRoot().performTouchInput { up() }
421         }
422 
423         fun continueDown() {
424             rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
425         }
426 
427         fun continueRight() {
428             rule.onRoot().performTouchInput { moveBy(Offset(touchSlop, 0f)) }
429         }
430 
431         startDraggingDown()
432         assertThat(verticalStarted).isTrue()
433         assertThat(verticalDragged).isTrue()
434         assertThat(verticalStopped).isFalse()
435 
436         // Ignore right swipe, do not interrupt the dragging gesture.
437         continueRight()
438         assertThat(horizontalStarted).isFalse()
439         assertThat(horizontalDragged).isFalse()
440         assertThat(horizontalStopped).isFalse()
441         assertThat(verticalStopped).isFalse()
442 
443         stopDragging()
444         assertThat(verticalStopped).isTrue()
445 
446         verticalStarted = false
447         verticalDragged = false
448         verticalStopped = false
449 
450         startDraggingRight()
451         assertThat(horizontalStarted).isTrue()
452         assertThat(horizontalDragged).isTrue()
453         assertThat(horizontalStopped).isFalse()
454 
455         // Ignore down swipe, do not interrupt the dragging gesture.
456         continueDown()
457         assertThat(verticalStarted).isFalse()
458         assertThat(verticalDragged).isFalse()
459         assertThat(verticalStopped).isFalse()
460         assertThat(horizontalStopped).isFalse()
461 
462         stopDragging()
463         assertThat(horizontalStopped).isTrue()
464     }
465 
466     @Test
467     fun multiPointerSwipeDetectorInteraction() {
468         val size = 200f
469         val middle = Offset(size / 2f, size / 2f)
470 
471         var started = false
472 
473         var capturedChange: PointerInputChange? = null
474         var swipeConsume = false
475 
476         var touchSlop = 0f
477         rule.setContent {
478             touchSlop = LocalViewConfiguration.current.touchSlop
479             Box(
480                 Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
481                     .multiPointerDraggable(
482                         orientation = Orientation.Vertical,
483                         enabled = { true },
484                         startDragImmediately = { false },
485                         swipeDetector =
486                             object : SwipeDetector {
487                                 override fun detectSwipe(change: PointerInputChange): Boolean {
488                                     capturedChange = change
489                                     return swipeConsume
490                                 }
491                             },
492                         onDragStarted = { _, _, _ ->
493                             started = true
494                             object : DragController {
495                                 override fun onDrag(delta: Float) {}
496 
497                                 override fun onStop(velocity: Float, canChangeScene: Boolean) {}
498                             }
499                         },
500                     )
501             ) {}
502         }
503 
504         fun startDraggingDown() {
505             rule.onRoot().performTouchInput {
506                 down(middle)
507                 moveBy(Offset(0f, touchSlop))
508             }
509         }
510 
511         fun continueDraggingDown() {
512             rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
513         }
514 
515         startDraggingDown()
516         assertThat(capturedChange).isNotNull()
517         capturedChange = null
518         assertThat(started).isFalse()
519 
520         swipeConsume = true
521         continueDraggingDown()
522         assertThat(capturedChange).isNotNull()
523         capturedChange = null
524 
525         continueDraggingDown()
526         assertThat(capturedChange).isNull()
527 
528         assertThat(started).isTrue()
529     }
530 }
531