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.gesture
18 
19 import androidx.compose.foundation.ScrollState
20 import androidx.compose.foundation.gestures.Orientation
21 import androidx.compose.foundation.horizontalScroll
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.foundation.layout.fillMaxSize
24 import androidx.compose.foundation.rememberScrollState
25 import androidx.compose.foundation.verticalScroll
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.setValue
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.geometry.Offset
33 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
34 import androidx.compose.ui.input.nestedscroll.nestedScroll
35 import androidx.compose.ui.platform.LocalViewConfiguration
36 import androidx.compose.ui.test.junit4.ComposeContentTestRule
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.compose.ui.test.swipeDown
41 import androidx.compose.ui.test.swipeLeft
42 import androidx.compose.ui.unit.Velocity
43 import com.google.common.truth.Truth.assertThat
44 import kotlin.math.ceil
45 import kotlinx.coroutines.awaitCancellation
46 import org.junit.Ignore
47 import org.junit.Rule
48 import org.junit.Test
49 import org.junit.runner.RunWith
50 import org.junit.runners.Parameterized
51 
52 @RunWith(Parameterized::class)
53 class NestedDraggableTest(override val orientation: Orientation) : OrientationAware {
54     companion object {
55         @Parameterized.Parameters(name = "{0}")
56         @JvmStatic
57         fun orientations() = listOf(Orientation.Horizontal, Orientation.Vertical)
58     }
59 
60     @get:Rule val rule = createComposeRule()
61 
62     @Test
63     fun simpleDrag() {
64         val draggable = TestDraggable()
65         val touchSlop =
66             rule.setContentWithTouchSlop {
67                 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
68             }
69 
70         assertThat(draggable.onDragStartedCalled).isFalse()
71         assertThat(draggable.onDragCalled).isFalse()
72         assertThat(draggable.onDragStoppedCalled).isFalse()
73 
74         var rootCenter = Offset.Zero
75         rule.onRoot().performTouchInput {
76             rootCenter = center
77             down(center)
78             moveBy((touchSlop + 10f).toOffset())
79         }
80 
81         assertThat(draggable.onDragStartedCalled).isTrue()
82         assertThat(draggable.onDragCalled).isTrue()
83         assertThat(draggable.onDragDelta).isEqualTo(10f)
84         assertThat(draggable.onDragStartedPosition).isEqualTo(rootCenter)
85         assertThat(draggable.onDragStartedSign).isEqualTo(1f)
86         assertThat(draggable.onDragStoppedCalled).isFalse()
87 
88         rule.onRoot().performTouchInput { moveBy(20f.toOffset()) }
89 
90         assertThat(draggable.onDragDelta).isEqualTo(30f)
91         assertThat(draggable.onDragStoppedCalled).isFalse()
92 
93         rule.onRoot().performTouchInput {
94             moveBy((-15f).toOffset())
95             up()
96         }
97 
98         assertThat(draggable.onDragDelta).isEqualTo(15f)
99         assertThat(draggable.onDragStoppedCalled).isTrue()
100     }
101 
102     @Test
103     fun nestedScrollable() {
104         val draggable = TestDraggable()
105         val touchSlop =
106             rule.setContentWithTouchSlop {
107                 Box(
108                     Modifier.fillMaxSize()
109                         .nestedDraggable(draggable, orientation)
110                         .nestedScrollable(rememberScrollState())
111                 )
112             }
113 
114         assertThat(draggable.onDragStartedCalled).isFalse()
115         assertThat(draggable.onDragCalled).isFalse()
116         assertThat(draggable.onDragStoppedCalled).isFalse()
117 
118         var rootCenter = Offset.Zero
119         rule.onRoot().performTouchInput {
120             rootCenter = center
121             down(center)
122             moveBy((-touchSlop - 10f).toOffset())
123         }
124 
125         assertThat(draggable.onDragStartedCalled).isTrue()
126         assertThat(draggable.onDragCalled).isTrue()
127         assertThat(draggable.onDragDelta).isEqualTo(-10f)
128         assertThat(draggable.onDragStartedPosition).isEqualTo(rootCenter)
129         assertThat(draggable.onDragStartedSign).isEqualTo(-1f)
130         assertThat(draggable.onDragStoppedCalled).isFalse()
131 
132         rule.onRoot().performTouchInput { moveBy((-20f).toOffset()) }
133 
134         assertThat(draggable.onDragStartedCalled).isTrue()
135         assertThat(draggable.onDragCalled).isTrue()
136         assertThat(draggable.onDragDelta).isEqualTo(-30f)
137         assertThat(draggable.onDragStoppedCalled).isFalse()
138 
139         rule.onRoot().performTouchInput {
140             moveBy(15f.toOffset())
141             up()
142         }
143 
144         assertThat(draggable.onDragStartedCalled).isTrue()
145         assertThat(draggable.onDragCalled).isTrue()
146         assertThat(draggable.onDragDelta).isEqualTo(-15f)
147         assertThat(draggable.onDragStoppedCalled).isTrue()
148     }
149 
150     @Test
151     fun onDragStoppedIsCalledWhenDraggableIsUpdatedAndReset() {
152         val draggable = TestDraggable()
153         var orientation by mutableStateOf(orientation)
154         val touchSlop =
155             rule.setContentWithTouchSlop {
156                 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
157             }
158 
159         assertThat(draggable.onDragStartedCalled).isFalse()
160 
161         rule.onRoot().performTouchInput {
162             down(center)
163             moveBy(touchSlop.toOffset())
164         }
165 
166         assertThat(draggable.onDragStartedCalled).isTrue()
167         assertThat(draggable.onDragStoppedCalled).isFalse()
168 
169         orientation =
170             when (orientation) {
171                 Orientation.Horizontal -> Orientation.Vertical
172                 Orientation.Vertical -> Orientation.Horizontal
173             }
174         rule.waitForIdle()
175         assertThat(draggable.onDragStoppedCalled).isTrue()
176     }
177 
178     @Test
179     fun onDragStoppedIsCalledWhenDraggableIsUpdatedAndReset_nestedScroll() {
180         val draggable = TestDraggable()
181         var orientation by mutableStateOf(orientation)
182         val touchSlop =
183             rule.setContentWithTouchSlop {
184                 Box(
185                     Modifier.fillMaxSize()
186                         .nestedDraggable(draggable, orientation)
187                         .nestedScrollable(rememberScrollState())
188                 )
189             }
190 
191         assertThat(draggable.onDragStartedCalled).isFalse()
192 
193         rule.onRoot().performTouchInput {
194             down(center)
195             moveBy((touchSlop + 1f).toOffset())
196         }
197 
198         assertThat(draggable.onDragStartedCalled).isTrue()
199         assertThat(draggable.onDragStoppedCalled).isFalse()
200 
201         orientation =
202             when (orientation) {
203                 Orientation.Horizontal -> Orientation.Vertical
204                 Orientation.Vertical -> Orientation.Horizontal
205             }
206         rule.waitForIdle()
207         assertThat(draggable.onDragStoppedCalled).isTrue()
208     }
209 
210     @Test
211     fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringDrag() {
212         val draggable = TestDraggable()
213         var composeContent by mutableStateOf(true)
214         val touchSlop =
215             rule.setContentWithTouchSlop {
216                 if (composeContent) {
217                     Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
218                 }
219             }
220 
221         assertThat(draggable.onDragStartedCalled).isFalse()
222 
223         rule.onRoot().performTouchInput {
224             down(center)
225             moveBy(touchSlop.toOffset())
226         }
227 
228         assertThat(draggable.onDragStartedCalled).isTrue()
229         assertThat(draggable.onDragStoppedCalled).isFalse()
230 
231         composeContent = false
232         rule.waitForIdle()
233         assertThat(draggable.onDragStoppedCalled).isTrue()
234     }
235 
236     @Test
237     fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringDrag_nestedScroll() {
238         val draggable = TestDraggable()
239         var composeContent by mutableStateOf(true)
240         val touchSlop =
241             rule.setContentWithTouchSlop {
242                 if (composeContent) {
243                     Box(
244                         Modifier.fillMaxSize()
245                             .nestedDraggable(draggable, orientation)
246                             .nestedScrollable(rememberScrollState())
247                     )
248                 }
249             }
250 
251         assertThat(draggable.onDragStartedCalled).isFalse()
252 
253         rule.onRoot().performTouchInput {
254             down(center)
255             moveBy((touchSlop + 1f).toOffset())
256         }
257 
258         assertThat(draggable.onDragStartedCalled).isTrue()
259         assertThat(draggable.onDragStoppedCalled).isFalse()
260 
261         composeContent = false
262         rule.waitForIdle()
263         assertThat(draggable.onDragStoppedCalled).isTrue()
264     }
265 
266     @Test
267     fun onDragStoppedIsCalledWhenDraggableIsRemovedDuringFling() {
268         val draggable = TestDraggable()
269         var composeContent by mutableStateOf(true)
270         var preFlingCalled = false
271         rule.setContent {
272             if (composeContent) {
273                 Box(
274                     Modifier.fillMaxSize()
275                         // This nested scroll connection indefinitely suspends on pre fling, so that
276                         // we can emulate what happens when the draggable is removed from
277                         // composition while the pre-fling happens and onDragStopped() was not
278                         // called yet.
279                         .nestedScroll(
280                             remember {
281                                 object : NestedScrollConnection {
282                                     override suspend fun onPreFling(available: Velocity): Velocity {
283                                         preFlingCalled = true
284                                         awaitCancellation()
285                                     }
286                                 }
287                             }
288                         )
289                         .nestedDraggable(draggable, orientation)
290                 )
291             }
292         }
293 
294         assertThat(draggable.onDragStartedCalled).isFalse()
295 
296         // Swipe down.
297         rule.onRoot().performTouchInput {
298             when (orientation) {
299                 Orientation.Horizontal -> swipeLeft()
300                 Orientation.Vertical -> swipeDown()
301             }
302         }
303 
304         assertThat(draggable.onDragStartedCalled).isTrue()
305         assertThat(draggable.onDragStoppedCalled).isFalse()
306         assertThat(preFlingCalled).isTrue()
307 
308         composeContent = false
309         rule.waitForIdle()
310         assertThat(draggable.onDragStoppedCalled).isTrue()
311     }
312 
313     @Test
314     @Ignore("b/303224944#comment22")
315     fun onDragStoppedIsCalledWhenNestedScrollableIsRemoved() {
316         val draggable = TestDraggable()
317         var composeNestedScrollable by mutableStateOf(true)
318         val touchSlop =
319             rule.setContentWithTouchSlop {
320                 Box(
321                     Modifier.fillMaxSize()
322                         .nestedDraggable(draggable, orientation)
323                         .then(
324                             if (composeNestedScrollable) {
325                                 Modifier.nestedScrollable(rememberScrollState())
326                             } else {
327                                 Modifier
328                             }
329                         )
330                 )
331             }
332 
333         assertThat(draggable.onDragStartedCalled).isFalse()
334 
335         rule.onRoot().performTouchInput {
336             down(center)
337             moveBy((touchSlop + 1f).toOffset())
338         }
339 
340         assertThat(draggable.onDragStartedCalled).isTrue()
341         assertThat(draggable.onDragStoppedCalled).isFalse()
342 
343         composeNestedScrollable = false
344         rule.waitForIdle()
345         assertThat(draggable.onDragStoppedCalled).isTrue()
346     }
347 
348     @Test
349     fun enabled() {
350         val draggable = TestDraggable()
351         var enabled by mutableStateOf(false)
352         val touchSlop =
353             rule.setContentWithTouchSlop {
354                 Box(
355                     Modifier.fillMaxSize()
356                         .nestedDraggable(draggable, orientation, enabled = enabled)
357                 )
358             }
359 
360         assertThat(draggable.onDragStartedCalled).isFalse()
361 
362         rule.onRoot().performTouchInput {
363             down(center)
364             moveBy(touchSlop.toOffset())
365         }
366 
367         assertThat(draggable.onDragStartedCalled).isFalse()
368         assertThat(draggable.onDragStoppedCalled).isFalse()
369 
370         enabled = true
371         rule.onRoot().performTouchInput {
372             // Release previously up finger.
373             up()
374 
375             down(center)
376             moveBy(touchSlop.toOffset())
377         }
378 
379         assertThat(draggable.onDragStartedCalled).isTrue()
380         assertThat(draggable.onDragStoppedCalled).isFalse()
381 
382         enabled = false
383         rule.waitForIdle()
384         assertThat(draggable.onDragStoppedCalled).isTrue()
385     }
386 
387     @Test
388     fun pointersDown() {
389         val draggable = TestDraggable()
390         val touchSlop =
391             rule.setContentWithTouchSlop {
392                 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
393             }
394 
395         (1..5).forEach { nDown ->
396             rule.onRoot().performTouchInput {
397                 repeat(nDown) { pointerId -> down(pointerId, center) }
398 
399                 moveBy(pointerId = 0, touchSlop.toOffset())
400             }
401 
402             assertThat(draggable.onDragStartedPointersDown).isEqualTo(nDown)
403 
404             rule.onRoot().performTouchInput {
405                 repeat(nDown) { pointerId -> up(pointerId = pointerId) }
406             }
407         }
408     }
409 
410     @Test
411     fun pointersDown_nestedScroll() {
412         val draggable = TestDraggable()
413         val touchSlop =
414             rule.setContentWithTouchSlop {
415                 Box(
416                     Modifier.fillMaxSize()
417                         .nestedDraggable(draggable, orientation)
418                         .nestedScrollable(rememberScrollState())
419                 )
420             }
421 
422         (1..5).forEach { nDown ->
423             rule.onRoot().performTouchInput {
424                 repeat(nDown) { pointerId -> down(pointerId, center) }
425 
426                 moveBy(pointerId = 0, (touchSlop + 1f).toOffset())
427             }
428 
429             assertThat(draggable.onDragStartedPointersDown).isEqualTo(nDown)
430 
431             rule.onRoot().performTouchInput {
432                 repeat(nDown) { pointerId -> up(pointerId = pointerId) }
433             }
434         }
435     }
436 
437     @Test
438     fun pointersDown_downThenUpThenDown() {
439         val draggable = TestDraggable()
440         val touchSlop =
441             rule.setContentWithTouchSlop {
442                 Box(Modifier.fillMaxSize().nestedDraggable(draggable, orientation))
443             }
444 
445         val slopThird = ceil(touchSlop / 3f).toOffset()
446         rule.onRoot().performTouchInput {
447             repeat(5) { down(pointerId = it, center) } // + 5
448             moveBy(pointerId = 0, slopThird)
449 
450             listOf(2, 3).forEach { up(pointerId = it) } // - 2
451             moveBy(pointerId = 0, slopThird)
452 
453             listOf(5, 6, 7).forEach { down(pointerId = it, center) } // + 3
454             moveBy(pointerId = 0, slopThird)
455         }
456 
457         assertThat(draggable.onDragStartedPointersDown).isEqualTo(6)
458     }
459 
460     private fun ComposeContentTestRule.setContentWithTouchSlop(
461         content: @Composable () -> Unit
462     ): Float {
463         var touchSlop = 0f
464         setContent {
465             touchSlop = LocalViewConfiguration.current.touchSlop
466             content()
467         }
468         return touchSlop
469     }
470 
471     private fun Modifier.nestedScrollable(scrollState: ScrollState): Modifier {
472         return when (orientation) {
473             Orientation.Vertical -> verticalScroll(scrollState)
474             Orientation.Horizontal -> horizontalScroll(scrollState)
475         }
476     }
477 
478     private class TestDraggable(
479         private val onDragStarted: (Offset, Float) -> Unit = { _, _ -> },
480         private val onDrag: (Float) -> Float = { it },
481         private val onDragStopped: suspend (Float) -> Float = { it },
482         private val shouldConsumeNestedScroll: (Float) -> Boolean = { true },
483     ) : NestedDraggable {
484         var onDragStartedCalled = false
485         var onDragCalled = false
486         var onDragStoppedCalled = false
487 
488         var onDragStartedPosition = Offset.Zero
489         var onDragStartedSign = 0f
490         var onDragStartedPointersDown = 0
491         var onDragDelta = 0f
492 
493         override fun onDragStarted(
494             position: Offset,
495             sign: Float,
496             pointersDown: Int,
497         ): NestedDraggable.Controller {
498             onDragStartedCalled = true
499             onDragStartedPosition = position
500             onDragStartedSign = sign
501             onDragStartedPointersDown = pointersDown
502             onDragDelta = 0f
503 
504             onDragStarted.invoke(position, sign)
505             return object : NestedDraggable.Controller {
506                 override fun onDrag(delta: Float): Float {
507                     onDragCalled = true
508                     onDragDelta += delta
509                     return onDrag.invoke(delta)
510                 }
511 
512                 override suspend fun onDragStopped(velocity: Float): Float {
513                     onDragStoppedCalled = true
514                     return onDragStopped.invoke(velocity)
515                 }
516             }
517         }
518 
519         override fun shouldConsumeNestedScroll(sign: Float): Boolean {
520             return shouldConsumeNestedScroll.invoke(sign)
521         }
522     }
523 }
524