1 /*
<lambda>null2 * Copyright 2020 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.foundation
18
19 import androidx.compose.animation.core.DecayAnimationSpec
20 import androidx.compose.animation.core.keyframes
21 import androidx.compose.animation.core.tween
22 import androidx.compose.animation.rememberSplineBasedDecay
23 import androidx.compose.foundation.gestures.DefaultFlingBehavior
24 import androidx.compose.foundation.gestures.FlingBehavior
25 import androidx.compose.foundation.gestures.Orientation
26 import androidx.compose.foundation.gestures.ScrollScope
27 import androidx.compose.foundation.gestures.ScrollableDefaults
28 import androidx.compose.foundation.gestures.ScrollableState
29 import androidx.compose.foundation.gestures.animateScrollBy
30 import androidx.compose.foundation.gestures.awaitFirstDown
31 import androidx.compose.foundation.gestures.rememberScrollableState
32 import androidx.compose.foundation.gestures.scrollable
33 import androidx.compose.foundation.interaction.DragInteraction
34 import androidx.compose.foundation.interaction.Interaction
35 import androidx.compose.foundation.interaction.MutableInteractionSource
36 import androidx.compose.foundation.layout.Box
37 import androidx.compose.foundation.layout.Column
38 import androidx.compose.foundation.layout.Row
39 import androidx.compose.foundation.layout.Spacer
40 import androidx.compose.foundation.layout.fillMaxHeight
41 import androidx.compose.foundation.layout.fillMaxWidth
42 import androidx.compose.foundation.layout.height
43 import androidx.compose.foundation.layout.padding
44 import androidx.compose.foundation.layout.size
45 import androidx.compose.foundation.layout.width
46 import androidx.compose.foundation.layout.wrapContentWidth
47 import androidx.compose.foundation.lazy.LazyColumn
48 import androidx.compose.foundation.lazy.LazyListState
49 import androidx.compose.foundation.lazy.LazyRow
50 import androidx.compose.foundation.lazy.rememberLazyListState
51 import androidx.compose.foundation.relocation.BringIntoViewRequester
52 import androidx.compose.foundation.relocation.bringIntoViewRequester
53 import androidx.compose.foundation.text.matchers.isZero
54 import androidx.compose.runtime.Composable
55 import androidx.compose.runtime.CompositionLocalProvider
56 import androidx.compose.runtime.SideEffect
57 import androidx.compose.runtime.currentComposer
58 import androidx.compose.runtime.getValue
59 import androidx.compose.runtime.mutableFloatStateOf
60 import androidx.compose.runtime.mutableStateOf
61 import androidx.compose.runtime.rememberCoroutineScope
62 import androidx.compose.runtime.setValue
63 import androidx.compose.testutils.assertModifierIsPure
64 import androidx.compose.testutils.first
65 import androidx.compose.ui.Alignment
66 import androidx.compose.ui.ExperimentalComposeUiApi
67 import androidx.compose.ui.Modifier
68 import androidx.compose.ui.MotionDurationScale
69 import androidx.compose.ui.focus.FocusDirection
70 import androidx.compose.ui.focus.FocusManager
71 import androidx.compose.ui.focus.FocusRequester
72 import androidx.compose.ui.focus.focusRequester
73 import androidx.compose.ui.focus.onFocusChanged
74 import androidx.compose.ui.geometry.Offset
75 import androidx.compose.ui.graphics.Color
76 import androidx.compose.ui.input.key.Key
77 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
78 import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
79 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
80 import androidx.compose.ui.input.nestedscroll.nestedScroll
81 import androidx.compose.ui.input.pointer.PointerInputChange
82 import androidx.compose.ui.input.pointer.PointerInputScope
83 import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
84 import androidx.compose.ui.input.pointer.pointerInput
85 import androidx.compose.ui.input.pointer.util.VelocityTracker
86 import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
87 import androidx.compose.ui.materialize
88 import androidx.compose.ui.node.ModifierNodeElement
89 import androidx.compose.ui.node.TraversableNode
90 import androidx.compose.ui.platform.AbstractComposeView
91 import androidx.compose.ui.platform.InspectableValue
92 import androidx.compose.ui.platform.LocalDensity
93 import androidx.compose.ui.platform.LocalFocusManager
94 import androidx.compose.ui.platform.LocalViewConfiguration
95 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
96 import androidx.compose.ui.platform.testTag
97 import androidx.compose.ui.semantics.SemanticsActions
98 import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
99 import androidx.compose.ui.test.ExperimentalTestApi
100 import androidx.compose.ui.test.ScrollWheel
101 import androidx.compose.ui.test.SemanticsMatcher
102 import androidx.compose.ui.test.assert
103 import androidx.compose.ui.test.click
104 import androidx.compose.ui.test.junit4.ComposeContentTestRule
105 import androidx.compose.ui.test.junit4.createComposeRule
106 import androidx.compose.ui.test.onNodeWithTag
107 import androidx.compose.ui.test.onRoot
108 import androidx.compose.ui.test.performKeyInput
109 import androidx.compose.ui.test.performMouseInput
110 import androidx.compose.ui.test.performSemanticsAction
111 import androidx.compose.ui.test.performTouchInput
112 import androidx.compose.ui.test.pressKey
113 import androidx.compose.ui.test.requestFocus
114 import androidx.compose.ui.test.swipe
115 import androidx.compose.ui.test.swipeDown
116 import androidx.compose.ui.test.swipeLeft
117 import androidx.compose.ui.test.swipeRight
118 import androidx.compose.ui.test.swipeUp
119 import androidx.compose.ui.test.swipeWithVelocity
120 import androidx.compose.ui.unit.Density
121 import androidx.compose.ui.unit.Velocity
122 import androidx.compose.ui.unit.dp
123 import androidx.compose.ui.unit.times
124 import androidx.compose.ui.util.fastForEach
125 import androidx.test.espresso.Espresso.onView
126 import androidx.test.espresso.action.CoordinatesProvider
127 import androidx.test.espresso.action.GeneralLocation
128 import androidx.test.espresso.action.GeneralSwipeAction
129 import androidx.test.espresso.action.Press
130 import androidx.test.espresso.action.Swipe
131 import androidx.test.ext.junit.runners.AndroidJUnit4
132 import androidx.test.filters.LargeTest
133 import com.google.common.truth.Truth.assertThat
134 import com.google.common.truth.Truth.assertWithMessage
135 import kotlin.math.abs
136 import kotlin.math.absoluteValue
137 import kotlinx.coroutines.CancellationException
138 import kotlinx.coroutines.CoroutineScope
139 import kotlinx.coroutines.Job
140 import kotlinx.coroutines.coroutineScope
141 import kotlinx.coroutines.launch
142 import kotlinx.coroutines.runBlocking
143 import kotlinx.coroutines.withContext
144 import org.hamcrest.CoreMatchers.allOf
145 import org.hamcrest.CoreMatchers.instanceOf
146 import org.junit.After
147 import org.junit.Assert
148 import org.junit.Before
149 import org.junit.Rule
150 import org.junit.Test
151 import org.junit.runner.RunWith
152
153 @LargeTest
154 @RunWith(AndroidJUnit4::class)
155 class ScrollableTest {
156
157 @get:Rule val rule = createComposeRule()
158
159 private val scrollableBoxTag = "scrollableBox"
160
161 private lateinit var scope: CoroutineScope
162
163 private fun ComposeContentTestRule.setContentAndGetScope(content: @Composable () -> Unit) {
164 setContent {
165 val actualScope = rememberCoroutineScope()
166 SideEffect { scope = actualScope }
167 content()
168 }
169 }
170
171 @Before
172 fun before() {
173 isDebugInspectorInfoEnabled = true
174 }
175
176 @After
177 fun after() {
178 isDebugInspectorInfoEnabled = false
179 }
180
181 @Test
182 fun scrollable_horizontalScroll() {
183 var total = 0f
184 val controller =
185 ScrollableState(
186 consumeScrollDelta = {
187 total += it
188 it
189 }
190 )
191 setScrollableContent {
192 Modifier.scrollable(state = controller, orientation = Orientation.Horizontal)
193 }
194 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
195 this.swipe(
196 start = this.center,
197 end = Offset(this.center.x + 100f, this.center.y),
198 durationMillis = 100
199 )
200 }
201
202 val lastTotal =
203 rule.runOnIdle {
204 assertThat(total).isGreaterThan(0)
205 total
206 }
207 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
208 this.swipe(
209 start = this.center,
210 end = Offset(this.center.x, this.center.y + 100f),
211 durationMillis = 100
212 )
213 }
214
215 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
216 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
217 this.swipe(
218 start = this.center,
219 end = Offset(this.center.x - 100f, this.center.y),
220 durationMillis = 100
221 )
222 }
223 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
224 }
225
226 @Test
227 fun scrollable_horizontalScroll_mouseWheel() {
228 var total = 0f
229 val controller =
230 ScrollableState(
231 consumeScrollDelta = {
232 total += it
233 it
234 }
235 )
236 setScrollableContent {
237 Modifier.scrollable(state = controller, orientation = Orientation.Horizontal)
238 }
239 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
240 this.scroll(-100f, ScrollWheel.Horizontal)
241 }
242
243 val lastTotal =
244 rule.runOnIdle {
245 assertThat(total).isGreaterThan(0)
246 total
247 }
248
249 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
250 this.scroll(-100f, ScrollWheel.Vertical)
251 }
252
253 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
254 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
255 this.scroll(100f, ScrollWheel.Horizontal)
256 }
257 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
258 }
259
260 @Test
261 fun scrollable_horizontalScroll_mouseWheel_badMotionEvent() {
262 var total = 0f
263 val controller =
264 ScrollableState(
265 consumeScrollDelta = {
266 total += it
267 it
268 }
269 )
270 setScrollableContent {
271 Modifier.scrollable(state = controller, orientation = Orientation.Horizontal)
272 }
273 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
274 this.scroll(Float.NaN, ScrollWheel.Horizontal)
275 }
276
277 assertThat(total).isEqualTo(0)
278 }
279
280 /*
281 * Note: For keyboard scrolling to work (that is, scrolling based on the page up/down keys),
282 * at least one child within the scrollable must be focusable. (This matches the behavior in
283 * Views.)
284 */
285 @OptIn(ExperimentalTestApi::class)
286 @Test
287 fun scrollable_horizontalScroll_keyboardPageUpAndDown() {
288 var scrollAmount = 0f
289
290 val scrollableState =
291 ScrollableState(
292 consumeScrollDelta = {
293 scrollAmount += it
294 it
295 }
296 )
297
298 rule.setContent {
299 Row(
300 Modifier.fillMaxHeight()
301 .wrapContentWidth()
302 .background(Color.Red)
303 .scrollable(state = scrollableState, orientation = Orientation.Horizontal)
304 .padding(10.dp)
305 ) {
306 Box(
307 modifier =
308 Modifier.fillMaxHeight()
309 .testTag(scrollableBoxTag)
310 .width(50.dp)
311 .background(Color.Blue)
312 // Required for keyboard scrolling (page up/down keys) to work.
313 .focusable()
314 .padding(10.dp)
315 )
316
317 Spacer(modifier = Modifier.size(10.dp))
318
319 for (i in 0 until 40) {
320 val color =
321 if (i % 2 == 0) {
322 Color.Yellow
323 } else {
324 Color.Green
325 }
326
327 Box(
328 modifier =
329 Modifier.fillMaxHeight().width(50.dp).background(color).padding(10.dp)
330 )
331 Spacer(modifier = Modifier.size(10.dp))
332 }
333 }
334 }
335
336 rule.onNodeWithTag(scrollableBoxTag).requestFocus()
337 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageDown) }
338
339 rule.runOnIdle { assertThat(scrollAmount).isLessThan(0f) }
340
341 scrollAmount = 0f
342
343 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageUp) }
344
345 rule.runOnIdle { assertThat(scrollAmount).isGreaterThan(0f) }
346 }
347
348 @Test
349 fun scrollable_horizontalScroll_reverse() {
350 var total = 0f
351 val controller =
352 ScrollableState(
353 consumeScrollDelta = {
354 total += it
355 it
356 }
357 )
358 setScrollableContent {
359 Modifier.scrollable(
360 reverseDirection = true,
361 state = controller,
362 orientation = Orientation.Horizontal
363 )
364 }
365 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
366 this.swipe(
367 start = this.center,
368 end = Offset(this.center.x + 100f, this.center.y),
369 durationMillis = 100
370 )
371 }
372
373 val lastTotal =
374 rule.runOnIdle {
375 assertThat(total).isLessThan(0)
376 total
377 }
378 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
379 this.swipe(
380 start = this.center,
381 end = Offset(this.center.x, this.center.y + 100f),
382 durationMillis = 100
383 )
384 }
385
386 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
387 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
388 this.swipe(
389 start = this.center,
390 end = Offset(this.center.x - 100f, this.center.y),
391 durationMillis = 100
392 )
393 }
394 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
395 }
396
397 @Test
398 fun scrollable_horizontalScroll_reverse_mouseWheel() {
399 var total = 0f
400 val controller =
401 ScrollableState(
402 consumeScrollDelta = {
403 total += it
404 it
405 }
406 )
407 setScrollableContent {
408 Modifier.scrollable(
409 reverseDirection = true,
410 state = controller,
411 orientation = Orientation.Horizontal
412 )
413 }
414 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
415 this.scroll(-100f, ScrollWheel.Horizontal)
416 }
417
418 val lastTotal =
419 rule.runOnIdle {
420 assertThat(total).isLessThan(0)
421 total
422 }
423 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
424 this.scroll(-100f, ScrollWheel.Vertical)
425 }
426
427 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
428 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
429 this.scroll(100f, ScrollWheel.Horizontal)
430 }
431 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
432 }
433
434 @Test
435 fun scrollable_verticalScroll() {
436 var total = 0f
437 val controller =
438 ScrollableState(
439 consumeScrollDelta = {
440 total += it
441 it
442 }
443 )
444 setScrollableContent {
445 Modifier.scrollable(state = controller, orientation = Orientation.Vertical)
446 }
447 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
448 this.swipe(
449 start = this.center,
450 end = Offset(this.center.x, this.center.y + 100f),
451 durationMillis = 100
452 )
453 }
454
455 val lastTotal =
456 rule.runOnIdle {
457 assertThat(total).isGreaterThan(0)
458 total
459 }
460 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
461 this.swipe(
462 start = this.center,
463 end = Offset(this.center.x + 100f, this.center.y),
464 durationMillis = 100
465 )
466 }
467
468 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
469 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
470 this.swipe(
471 start = this.center,
472 end = Offset(this.center.x, this.center.y - 100f),
473 durationMillis = 100
474 )
475 }
476 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
477 }
478
479 @Test
480 fun scrollable_verticalScroll_mouseWheel() {
481 var total = 0f
482 val controller =
483 ScrollableState(
484 consumeScrollDelta = {
485 total += it
486 it
487 }
488 )
489 setScrollableContent {
490 Modifier.scrollable(state = controller, orientation = Orientation.Vertical)
491 }
492 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
493 this.scroll(-100f, ScrollWheel.Vertical)
494 }
495
496 val lastTotal =
497 rule.runOnIdle {
498 assertThat(total).isGreaterThan(0)
499 total
500 }
501 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
502 this.scroll(-100f, ScrollWheel.Horizontal)
503 }
504
505 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
506 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
507 this.scroll(100f, ScrollWheel.Vertical)
508 }
509 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
510 }
511
512 @Test
513 fun scrollable_verticalScroll_mouseWheel_badMotionEvent() {
514 var total = 0f
515 val controller =
516 ScrollableState(
517 consumeScrollDelta = {
518 total += it
519 it
520 }
521 )
522 setScrollableContent {
523 Modifier.scrollable(state = controller, orientation = Orientation.Vertical)
524 }
525 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
526 this.scroll(Float.NaN, ScrollWheel.Vertical)
527 }
528
529 assertThat(total).isEqualTo(0)
530 }
531
532 /*
533 * Note: For keyboard scrolling to work (that is, scrolling based on the page up/down keys),
534 * at least one child within the scrollable must be focusable. (This matches the behavior in
535 * Views.)
536 */
537 @OptIn(ExperimentalTestApi::class)
538 @Test
539 fun scrollable_verticalScroll_keyboardPageUpAndDown() {
540 var scrollAmount = 0f
541
542 val scrollableState =
543 ScrollableState(
544 consumeScrollDelta = {
545 scrollAmount += it
546 it
547 }
548 )
549
550 rule.setContent {
551 Column(
552 Modifier.fillMaxWidth()
553 .background(Color.Red)
554 .scrollable(state = scrollableState, orientation = Orientation.Vertical)
555 .padding(10.dp)
556 ) {
557 Box(
558 modifier =
559 Modifier.fillMaxWidth()
560 .testTag(scrollableBoxTag)
561 .height(50.dp)
562 .background(Color.Blue)
563 // Required for keyboard scrolling (page up/down keys) to work.
564 .focusable()
565 .padding(10.dp)
566 )
567
568 Spacer(modifier = Modifier.size(10.dp))
569
570 for (i in 0 until 40) {
571 val color =
572 if (i % 2 == 0) {
573 Color.Yellow
574 } else {
575 Color.Green
576 }
577
578 Box(
579 modifier =
580 Modifier.fillMaxWidth().height(50.dp).background(color).padding(10.dp)
581 )
582 Spacer(modifier = Modifier.size(10.dp))
583 }
584 }
585 }
586
587 rule.onNodeWithTag(scrollableBoxTag).requestFocus()
588 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageDown) }
589
590 rule.runOnIdle { assertThat(scrollAmount).isLessThan(0f) }
591
592 scrollAmount = 0f
593
594 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageUp) }
595
596 rule.runOnIdle { assertThat(scrollAmount).isGreaterThan(0f) }
597 }
598
599 @Test
600 fun scrollable_verticalScroll_reversed() {
601 var total = 0f
602 val controller =
603 ScrollableState(
604 consumeScrollDelta = {
605 total += it
606 it
607 }
608 )
609 setScrollableContent {
610 Modifier.scrollable(
611 reverseDirection = true,
612 state = controller,
613 orientation = Orientation.Vertical
614 )
615 }
616 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
617 this.swipe(
618 start = this.center,
619 end = Offset(this.center.x, this.center.y + 100f),
620 durationMillis = 100
621 )
622 }
623
624 val lastTotal =
625 rule.runOnIdle {
626 assertThat(total).isLessThan(0)
627 total
628 }
629 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
630 this.swipe(
631 start = this.center,
632 end = Offset(this.center.x + 100f, this.center.y),
633 durationMillis = 100
634 )
635 }
636
637 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
638 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
639 this.swipe(
640 start = this.center,
641 end = Offset(this.center.x, this.center.y - 100f),
642 durationMillis = 100
643 )
644 }
645 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
646 }
647
648 @Test
649 fun scrollable_verticalScroll_reversed_mouseWheel() {
650 var total = 0f
651 val controller =
652 ScrollableState(
653 consumeScrollDelta = {
654 total += it
655 it
656 }
657 )
658 setScrollableContent {
659 Modifier.scrollable(
660 reverseDirection = true,
661 state = controller,
662 orientation = Orientation.Vertical
663 )
664 }
665 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
666 this.scroll(-100f, ScrollWheel.Vertical)
667 }
668
669 val lastTotal =
670 rule.runOnIdle {
671 assertThat(total).isLessThan(0)
672 total
673 }
674
675 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
676 this.scroll(-100f, ScrollWheel.Horizontal)
677 }
678
679 rule.runOnIdle { assertThat(total).isEqualTo(lastTotal) }
680 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
681 this.scroll(100f, ScrollWheel.Vertical)
682 }
683 rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
684 }
685
686 @Test
687 fun scrollable_disabledWontCallLambda() {
688 val enabled = mutableStateOf(true)
689 var total = 0f
690 val controller =
691 ScrollableState(
692 consumeScrollDelta = {
693 total += it
694 it
695 }
696 )
697 setScrollableContent {
698 Modifier.scrollable(
699 state = controller,
700 orientation = Orientation.Horizontal,
701 enabled = enabled.value
702 )
703 }
704 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
705 this.swipe(
706 start = this.center,
707 end = Offset(this.center.x + 100f, this.center.y),
708 durationMillis = 100
709 )
710 }
711 val prevTotal =
712 rule.runOnIdle {
713 assertThat(total).isGreaterThan(0f)
714 enabled.value = false
715 total
716 }
717 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
718 this.swipe(
719 start = this.center,
720 end = Offset(this.center.x + 100f, this.center.y),
721 durationMillis = 100
722 )
723 }
724 rule.runOnIdle { assertThat(total).isEqualTo(prevTotal) }
725 }
726
727 @Test
728 fun scrollable_startWithoutSlop_ifFlinging() {
729 rule.mainClock.autoAdvance = false
730 var total = 0f
731 val controller =
732 ScrollableState(
733 consumeScrollDelta = {
734 total += it
735 it
736 }
737 )
738 setScrollableContent {
739 Modifier.scrollable(state = controller, orientation = Orientation.Horizontal)
740 }
741 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
742 swipeWithVelocity(
743 start = this.center,
744 end = Offset(this.center.x + 200f, this.center.y),
745 durationMillis = 100,
746 endVelocity = 4000f
747 )
748 }
749 assertThat(total).isGreaterThan(0f)
750 val prev = total
751 // pump frames twice to start fling animation
752 rule.mainClock.advanceTimeByFrame()
753 rule.mainClock.advanceTimeByFrame()
754 val prevAfterSomeFling = total
755 assertThat(prevAfterSomeFling).isGreaterThan(prev)
756 // don't advance main clock anymore since we're in the middle of the fling. Now interrupt
757 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
758 down(this.center)
759 moveBy(Offset(115f, 0f))
760 up()
761 }
762 val expected = prevAfterSomeFling + 115
763 assertThat(total).isEqualTo(expected)
764 }
765
766 @Test
767 fun scrollable_blocksDownEvents_ifFlingingCaught() {
768 rule.mainClock.autoAdvance = false
769 var total = 0f
770 val controller =
771 ScrollableState(
772 consumeScrollDelta = {
773 total += it
774 it
775 }
776 )
777 rule.setContent {
778 Box {
779 Box(
780 contentAlignment = Alignment.Center,
781 modifier =
782 Modifier.size(300.dp)
783 .scrollable(orientation = Orientation.Horizontal, state = controller)
784 ) {
785 Box(
786 modifier =
787 Modifier.size(300.dp).testTag(scrollableBoxTag).clickable {
788 assertWithMessage("Clickable shouldn't click when fling caught")
789 .fail()
790 }
791 )
792 }
793 }
794 }
795 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
796 swipeWithVelocity(
797 start = this.center,
798 end = Offset(this.center.x + 200f, this.center.y),
799 durationMillis = 100,
800 endVelocity = 4000f
801 )
802 }
803 assertThat(total).isGreaterThan(0f)
804 val prev = total
805 // pump frames twice to start fling animation
806 rule.mainClock.advanceTimeByFrame()
807 rule.mainClock.advanceTimeByFrame()
808 val prevAfterSomeFling = total
809 assertThat(prevAfterSomeFling).isGreaterThan(prev)
810 // don't advance main clock anymore since we're in the middle of the fling. Now interrupt
811 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
812 down(this.center)
813 up()
814 }
815 // shouldn't assert in clickable lambda
816 }
817
818 @Test
819 fun scrollable_snappingScrolling() {
820 var total = 0f
821 val controller =
822 ScrollableState(
823 consumeScrollDelta = {
824 total += it
825 it
826 }
827 )
828 setScrollableContent {
829 Modifier.scrollable(orientation = Orientation.Vertical, state = controller)
830 }
831 rule.waitForIdle()
832 assertThat(total).isEqualTo(0f)
833
834 scope.launch { controller.animateScrollBy(1000f) }
835 rule.waitForIdle()
836 assertThat(total).isWithin(0.001f).of(1000f)
837
838 scope.launch { controller.animateScrollBy(-200f) }
839 rule.waitForIdle()
840 assertThat(total).isWithin(0.001f).of(800f)
841 }
842
843 @Test
844 fun scrollable_explicitDisposal() {
845 rule.mainClock.autoAdvance = false
846 val emit = mutableStateOf(true)
847 val expectEmission = mutableStateOf(true)
848 var total = 0f
849 val controller =
850 ScrollableState(
851 consumeScrollDelta = {
852 assertWithMessage("Animating after dispose!")
853 .that(expectEmission.value)
854 .isTrue()
855 total += it
856 it
857 }
858 )
859 setScrollableContent {
860 if (emit.value) {
861 Modifier.scrollable(orientation = Orientation.Horizontal, state = controller)
862 } else {
863 Modifier
864 }
865 }
866 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
867 this.swipeWithVelocity(
868 start = this.center,
869 end = Offset(this.center.x + 200f, this.center.y),
870 durationMillis = 100,
871 endVelocity = 4000f
872 )
873 }
874 assertThat(total).isGreaterThan(0f)
875
876 // start the fling for a few frames
877 rule.mainClock.advanceTimeByFrame()
878 rule.mainClock.advanceTimeByFrame()
879 // flip the emission
880 rule.runOnUiThread { emit.value = false }
881 // propagate the emit flip and record the value
882 rule.mainClock.advanceTimeByFrame()
883 val prevTotal = total
884 // make sure we don't receive any deltas
885 rule.runOnUiThread { expectEmission.value = false }
886
887 // pump the clock until idle
888 rule.mainClock.autoAdvance = true
889 rule.waitForIdle()
890
891 // still same and didn't fail in onScrollConsumptionRequested.. lambda
892 assertThat(total).isEqualTo(prevTotal)
893 }
894
895 @Test
896 fun scrollable_nestedDrag() {
897 var innerDrag = 0f
898 var outerDrag = 0f
899 val outerState =
900 ScrollableState(
901 consumeScrollDelta = {
902 outerDrag += it
903 it
904 }
905 )
906 val innerState =
907 ScrollableState(
908 consumeScrollDelta = {
909 innerDrag += it / 2
910 it / 2
911 }
912 )
913
914 rule.setContentAndGetScope {
915 Box {
916 Box(
917 contentAlignment = Alignment.Center,
918 modifier =
919 Modifier.size(300.dp)
920 .scrollable(state = outerState, orientation = Orientation.Horizontal)
921 ) {
922 Box(
923 modifier =
924 Modifier.testTag(scrollableBoxTag)
925 .size(300.dp)
926 .scrollable(
927 state = innerState,
928 orientation = Orientation.Horizontal
929 )
930 )
931 }
932 }
933 }
934 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
935 this.swipeWithVelocity(
936 start = this.center,
937 end = Offset(this.center.x + 200f, this.center.y),
938 durationMillis = 300,
939 endVelocity = 0f
940 )
941 }
942 val lastEqualDrag =
943 rule.runOnIdle {
944 assertThat(innerDrag).isGreaterThan(0f)
945 assertThat(outerDrag).isGreaterThan(0f)
946 // we consumed half delta in child, so exactly half should go to the parent
947 assertThat(outerDrag).isEqualTo(innerDrag)
948 innerDrag
949 }
950 rule.runOnIdle {
951 // values should be the same since no fling
952 assertThat(innerDrag).isEqualTo(lastEqualDrag)
953 assertThat(outerDrag).isEqualTo(lastEqualDrag)
954 }
955 }
956
957 @Test
958 fun scrollable_nestedScroll_childPartialConsumptionForMouseWheel() {
959 var innerDrag = 0f
960 var outerDrag = 0f
961 val outerState =
962 ScrollableState(
963 consumeScrollDelta = {
964 // Since the child has already consumed half, the parent will consume the rest.
965 outerDrag += it
966 it
967 }
968 )
969 val innerState =
970 ScrollableState(
971 consumeScrollDelta = {
972 // Child consumes half, leaving the rest for the parent to consume.
973 innerDrag += it / 2
974 it / 2
975 }
976 )
977
978 rule.setContentAndGetScope {
979 Box {
980 Box(
981 contentAlignment = Alignment.Center,
982 modifier =
983 Modifier.size(300.dp)
984 .scrollable(state = outerState, orientation = Orientation.Horizontal)
985 ) {
986 Box(
987 modifier =
988 Modifier.testTag(scrollableBoxTag)
989 .size(300.dp)
990 .scrollable(
991 state = innerState,
992 orientation = Orientation.Horizontal
993 )
994 )
995 }
996 }
997 }
998 rule.onNodeWithTag(scrollableBoxTag).performMouseInput {
999 this.scroll(-200f, ScrollWheel.Horizontal)
1000 }
1001 rule.runOnIdle {
1002 assertThat(innerDrag).isGreaterThan(0f)
1003 assertThat(outerDrag).isGreaterThan(0f)
1004 // Since child (inner) consumes half of the scroll, the parent (outer) consumes the
1005 // remainder (which is half as well), so they will be equal.
1006 assertThat(innerDrag).isEqualTo(outerDrag)
1007 innerDrag
1008 }
1009 }
1010
1011 /*
1012 * Note: For keyboard scrolling to work (that is, scrolling based on the page up/down keys),
1013 * at least one child within the scrollable must be focusable. (This matches the behavior in
1014 * Views.)
1015 */
1016 @OptIn(ExperimentalTestApi::class)
1017 @Test
1018 fun scrollable_nestedScroll_childPartialConsumptionForKeyboardPageUpAndDown() {
1019 var innerDrag = 0f
1020 var outerDrag = 0f
1021 val outerState =
1022 ScrollableState(
1023 consumeScrollDelta = {
1024 // Since the child has already consumed half, the parent will consume the rest.
1025 outerDrag += it
1026 it
1027 }
1028 )
1029 val innerState =
1030 ScrollableState(
1031 consumeScrollDelta = {
1032 // Child consumes half, leaving the rest for the parent to consume.
1033 innerDrag += it / 2
1034 it / 2
1035 }
1036 )
1037
1038 rule.setContent {
1039 Box {
1040 Box(
1041 contentAlignment = Alignment.Center,
1042 modifier =
1043 Modifier.size(300.dp)
1044 .scrollable(state = outerState, orientation = Orientation.Horizontal)
1045 ) {
1046 Box(
1047 modifier =
1048 Modifier.size(300.dp)
1049 .scrollable(
1050 state = innerState,
1051 orientation = Orientation.Horizontal
1052 )
1053 ) {
1054 Box(
1055 modifier =
1056 Modifier.testTag(scrollableBoxTag)
1057 // Required for keyboard scrolling (page up/down keys) to work.
1058 .focusable()
1059 .size(300.dp)
1060 )
1061 }
1062 }
1063 }
1064 }
1065
1066 rule.onNodeWithTag(scrollableBoxTag).requestFocus()
1067 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageDown) }
1068
1069 rule.runOnIdle {
1070 assertThat(outerDrag).isLessThan(0f)
1071 assertThat(innerDrag).isLessThan(0f)
1072 // Since child (inner) consumes half of the scroll, the parent (outer) consumes the
1073 // remainder (which is half as well), so they will be equal.
1074 assertThat(innerDrag).isEqualTo(outerDrag)
1075 }
1076
1077 outerDrag = 0f
1078 innerDrag = 0f
1079
1080 rule.onNodeWithTag(scrollableBoxTag).performKeyInput { pressKey(Key.PageUp) }
1081
1082 rule.runOnIdle {
1083 assertThat(outerDrag).isGreaterThan(0f)
1084 assertThat(innerDrag).isGreaterThan(0f)
1085 // Since child (inner) consumes half of the scroll, the parent (outer) consumes the
1086 // remainder (which is half as well), so they will be equal.
1087 assertThat(innerDrag).isEqualTo(outerDrag)
1088 }
1089 }
1090
1091 @Test
1092 fun scrollable_nestedScroll_childPartialConsumptionForSemantics_horizontal() {
1093 var innerDrag = 0f
1094 var outerDrag = 0f
1095 val outerState =
1096 ScrollableState(
1097 consumeScrollDelta = {
1098 // Since the child has already consumed half, the parent will consume the rest.
1099 outerDrag += it
1100 it
1101 }
1102 )
1103 val innerState =
1104 ScrollableState(
1105 consumeScrollDelta = {
1106 // Child consumes half, leaving the rest for the parent to consume.
1107 innerDrag += it / 2
1108 it / 2
1109 }
1110 )
1111
1112 rule.setContentAndGetScope {
1113 Box {
1114 Box(
1115 contentAlignment = Alignment.Center,
1116 modifier =
1117 Modifier.size(300.dp)
1118 .scrollable(state = outerState, orientation = Orientation.Horizontal)
1119 ) {
1120 Box(
1121 modifier =
1122 Modifier.testTag(scrollableBoxTag)
1123 .size(300.dp)
1124 .scrollable(
1125 state = innerState,
1126 orientation = Orientation.Horizontal
1127 )
1128 )
1129 }
1130 }
1131 }
1132 rule.onNodeWithTag(scrollableBoxTag).performSemanticsAction(ScrollBy) {
1133 it.invoke(200f, 0f)
1134 }
1135
1136 rule.runOnIdle {
1137 assertThat(innerDrag).isGreaterThan(0f)
1138 assertThat(outerDrag).isGreaterThan(0f)
1139 assertThat(innerDrag).isEqualTo(outerDrag)
1140 innerDrag
1141 }
1142 }
1143
1144 @Test
1145 fun scrollable_nestedScroll_childPartialConsumptionForSemantics_vertical() {
1146 var innerDrag = 0f
1147 var outerDrag = 0f
1148 val outerState =
1149 ScrollableState(
1150 consumeScrollDelta = {
1151 outerDrag += it
1152 it
1153 }
1154 )
1155 val innerState =
1156 ScrollableState(
1157 consumeScrollDelta = {
1158 innerDrag += it / 2
1159 it / 2
1160 }
1161 )
1162
1163 rule.setContentAndGetScope {
1164 Box {
1165 Box(
1166 contentAlignment = Alignment.Center,
1167 modifier =
1168 Modifier.size(300.dp)
1169 .scrollable(state = outerState, orientation = Orientation.Vertical)
1170 ) {
1171 Box(
1172 modifier =
1173 Modifier.testTag(scrollableBoxTag)
1174 .size(300.dp)
1175 .scrollable(state = innerState, orientation = Orientation.Vertical)
1176 )
1177 }
1178 }
1179 }
1180
1181 rule.onNodeWithTag(scrollableBoxTag).performSemanticsAction(ScrollBy) {
1182 it.invoke(0f, 200f)
1183 }
1184
1185 rule.runOnIdle {
1186 assertThat(innerDrag).isGreaterThan(0f)
1187 assertThat(outerDrag).isGreaterThan(0f)
1188 assertThat(innerDrag).isEqualTo(outerDrag)
1189 innerDrag
1190 }
1191 }
1192
1193 @Test
1194 fun focusScroll_nestedScroll_childPartialConsumptionForSemantics() {
1195 var outerDrag = 0f
1196 val requester = BringIntoViewRequester()
1197 val connection =
1198 object : NestedScrollConnection {
1199 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
1200 outerDrag += available.x
1201 return super.onPreScroll(available, source)
1202 }
1203 }
1204 val scrollState = ScrollState(0)
1205 rule.setContentAndGetScope {
1206 Box(Modifier.nestedScroll(connection)) {
1207 Row(modifier = Modifier.size(300.dp).horizontalScroll(scrollState)) {
1208 repeat(5) { Box(modifier = Modifier.testTag(scrollableBoxTag).size(100.dp)) }
1209 Box(
1210 modifier =
1211 Modifier.testTag(scrollableBoxTag)
1212 .size(100.dp)
1213 .bringIntoViewRequester(requester)
1214 )
1215 }
1216 }
1217 }
1218
1219 rule.runOnIdle { scope.launch { requester.bringIntoView() } }
1220
1221 rule.runOnIdle {
1222 assertThat(outerDrag).isNonZero()
1223 assertThat(outerDrag).isWithin(1f).of(-scrollState.value.toFloat())
1224 }
1225 }
1226
1227 @Test
1228 fun scrollable_nestedFling() {
1229 var innerDrag = 0f
1230 var outerDrag = 0f
1231 val outerState =
1232 ScrollableState(
1233 consumeScrollDelta = {
1234 outerDrag += it
1235 it
1236 }
1237 )
1238 val innerState =
1239 ScrollableState(
1240 consumeScrollDelta = {
1241 innerDrag += it / 2
1242 it / 2
1243 }
1244 )
1245
1246 rule.setContentAndGetScope {
1247 Box {
1248 Box(
1249 contentAlignment = Alignment.Center,
1250 modifier =
1251 Modifier.size(300.dp)
1252 .scrollable(state = outerState, orientation = Orientation.Horizontal)
1253 ) {
1254 Box(
1255 modifier =
1256 Modifier.testTag(scrollableBoxTag)
1257 .size(300.dp)
1258 .scrollable(
1259 state = innerState,
1260 orientation = Orientation.Horizontal
1261 )
1262 )
1263 }
1264 }
1265 }
1266
1267 // swipe again with velocity
1268 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1269 this.swipe(
1270 start = this.center,
1271 end = Offset(this.center.x + 200f, this.center.y),
1272 durationMillis = 300
1273 )
1274 }
1275 assertThat(innerDrag).isGreaterThan(0f)
1276 assertThat(outerDrag).isGreaterThan(0f)
1277 // we consumed half delta in child, so exactly half should go to the parent
1278 assertThat(outerDrag).isEqualTo(innerDrag)
1279 val lastEqualDrag = innerDrag
1280 rule.runOnIdle {
1281 assertThat(innerDrag).isGreaterThan(lastEqualDrag)
1282 assertThat(outerDrag).isGreaterThan(lastEqualDrag)
1283 }
1284 }
1285
1286 @Test
1287 fun scrollable_nestedScrollAbove_respectsPreConsumption() {
1288 var value = 0f
1289 var lastReceivedPreScrollAvailable = 0f
1290 val preConsumeFraction = 0.7f
1291 val controller =
1292 ScrollableState(
1293 consumeScrollDelta = {
1294 val expected = lastReceivedPreScrollAvailable * (1 - preConsumeFraction)
1295 assertThat(it - expected).isWithin(0.01f)
1296 value += it
1297 it
1298 }
1299 )
1300 val preConsumingParent =
1301 object : NestedScrollConnection {
1302 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
1303 lastReceivedPreScrollAvailable = available.x
1304 return available * preConsumeFraction
1305 }
1306
1307 override suspend fun onPreFling(available: Velocity): Velocity {
1308 // consume all velocity
1309 return available
1310 }
1311 }
1312
1313 rule.setContentAndGetScope {
1314 Box {
1315 Box(
1316 contentAlignment = Alignment.Center,
1317 modifier = Modifier.size(300.dp).nestedScroll(preConsumingParent)
1318 ) {
1319 Box(
1320 modifier =
1321 Modifier.size(300.dp)
1322 .testTag(scrollableBoxTag)
1323 .scrollable(
1324 state = controller,
1325 orientation = Orientation.Horizontal
1326 )
1327 )
1328 }
1329 }
1330 }
1331
1332 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1333 this.swipe(
1334 start = this.center,
1335 end = Offset(this.center.x + 200f, this.center.y),
1336 durationMillis = 300
1337 )
1338 }
1339
1340 val preFlingValue = rule.runOnIdle { value }
1341 rule.runOnIdle {
1342 // if scrollable respects pre-fling consumption, it should fling 0px since we
1343 // pre-consume all
1344 assertThat(preFlingValue).isEqualTo(value)
1345 }
1346 }
1347
1348 @Test
1349 fun scrollable_nestedScrollAbove_proxiesPostCycles() {
1350 var value = 0f
1351 var expectedLeft = 0f
1352 val velocityFlung = 5000f
1353 val controller =
1354 ScrollableState(
1355 consumeScrollDelta = {
1356 val toConsume = it * 0.345f
1357 value += toConsume
1358 expectedLeft = it - toConsume
1359 toConsume
1360 }
1361 )
1362 val parent =
1363 object : NestedScrollConnection {
1364 override fun onPostScroll(
1365 consumed: Offset,
1366 available: Offset,
1367 source: NestedScrollSource
1368 ): Offset {
1369 // we should get in post scroll as much as left in controller callback
1370 assertThat(available.x).isEqualTo(expectedLeft)
1371 return if (source == NestedScrollSource.SideEffect) Offset.Zero else available
1372 }
1373
1374 override suspend fun onPostFling(
1375 consumed: Velocity,
1376 available: Velocity
1377 ): Velocity {
1378 val expected = velocityFlung - consumed.x
1379 assertThat(consumed.x).isLessThan(velocityFlung)
1380 assertThat(abs(available.x - expected)).isLessThan(0.1f)
1381 return available
1382 }
1383 }
1384
1385 rule.setContentAndGetScope {
1386 Box {
1387 Box(
1388 contentAlignment = Alignment.Center,
1389 modifier = Modifier.size(300.dp).nestedScroll(parent)
1390 ) {
1391 Box(
1392 modifier =
1393 Modifier.size(300.dp)
1394 .testTag(scrollableBoxTag)
1395 .scrollable(
1396 state = controller,
1397 orientation = Orientation.Horizontal
1398 )
1399 )
1400 }
1401 }
1402 }
1403
1404 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1405 this.swipeWithVelocity(
1406 start = this.center,
1407 end = Offset(this.center.x + 500f, this.center.y),
1408 durationMillis = 300,
1409 endVelocity = velocityFlung
1410 )
1411 }
1412
1413 // all assertions in callback above
1414 rule.waitForIdle()
1415 }
1416
1417 @Test
1418 fun scrollable_nestedScrollAbove_reversed_proxiesPostCycles() {
1419 var value = 0f
1420 var expectedLeft = 0f
1421 val velocityFlung = 5000f
1422 val controller =
1423 ScrollableState(
1424 consumeScrollDelta = {
1425 val toConsume = it * 0.345f
1426 value += toConsume
1427 expectedLeft = it - toConsume
1428 toConsume
1429 }
1430 )
1431 val parent =
1432 object : NestedScrollConnection {
1433 override fun onPostScroll(
1434 consumed: Offset,
1435 available: Offset,
1436 source: NestedScrollSource
1437 ): Offset {
1438 // we should get in post scroll as much as left in controller callback
1439 assertThat(available.x).isEqualTo(-expectedLeft)
1440 return if (source == NestedScrollSource.SideEffect) Offset.Zero else available
1441 }
1442
1443 override suspend fun onPostFling(
1444 consumed: Velocity,
1445 available: Velocity
1446 ): Velocity {
1447 val expected = velocityFlung - consumed.x
1448 assertThat(consumed.x).isLessThan(velocityFlung)
1449 assertThat(abs(available.x - expected)).isLessThan(0.1f)
1450 return available
1451 }
1452 }
1453
1454 rule.setContentAndGetScope {
1455 Box {
1456 Box(
1457 contentAlignment = Alignment.Center,
1458 modifier = Modifier.size(300.dp).nestedScroll(parent)
1459 ) {
1460 Box(
1461 modifier =
1462 Modifier.size(300.dp)
1463 .testTag(scrollableBoxTag)
1464 .scrollable(
1465 state = controller,
1466 reverseDirection = true,
1467 orientation = Orientation.Horizontal
1468 )
1469 )
1470 }
1471 }
1472 }
1473
1474 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1475 this.swipeWithVelocity(
1476 start = this.center,
1477 end = Offset(this.center.x + 500f, this.center.y),
1478 durationMillis = 300,
1479 endVelocity = velocityFlung
1480 )
1481 }
1482
1483 // all assertions in callback above
1484 rule.waitForIdle()
1485 }
1486
1487 @Test
1488 fun scrollable_nestedScrollBelow_listensDispatches() {
1489 var value = 0f
1490 var expectedConsumed = 0f
1491 val controller =
1492 ScrollableState(
1493 consumeScrollDelta = {
1494 expectedConsumed = it * 0.3f
1495 value += expectedConsumed
1496 expectedConsumed
1497 }
1498 )
1499 val child = object : NestedScrollConnection {}
1500 val dispatcher = NestedScrollDispatcher()
1501
1502 rule.setContentAndGetScope {
1503 Box {
1504 Box(
1505 modifier =
1506 Modifier.size(300.dp)
1507 .scrollable(state = controller, orientation = Orientation.Horizontal)
1508 ) {
1509 Box(
1510 Modifier.size(200.dp)
1511 .testTag(scrollableBoxTag)
1512 .nestedScroll(child, dispatcher)
1513 )
1514 }
1515 }
1516 }
1517
1518 val lastValueBeforeFling =
1519 rule.runOnIdle {
1520 val preScrollConsumed =
1521 dispatcher.dispatchPreScroll(Offset(20f, 20f), NestedScrollSource.UserInput)
1522 // scrollable is not interested in pre scroll
1523 assertThat(preScrollConsumed).isEqualTo(Offset.Zero)
1524
1525 val consumed =
1526 dispatcher.dispatchPostScroll(
1527 Offset(20f, 20f),
1528 Offset(50f, 50f),
1529 NestedScrollSource.UserInput
1530 )
1531 assertThat(consumed.x - expectedConsumed).isWithin(0.001f)
1532 value
1533 }
1534
1535 scope.launch {
1536 val preFlingConsumed = dispatcher.dispatchPreFling(Velocity(50f, 50f))
1537 // scrollable won't participate in the pre fling
1538 assertThat(preFlingConsumed).isEqualTo(Velocity.Zero)
1539 }
1540 rule.waitForIdle()
1541
1542 scope.launch {
1543 dispatcher.dispatchPostFling(Velocity(1000f, 1000f), Velocity(2000f, 2000f))
1544 }
1545
1546 rule.runOnIdle {
1547 // catch that scrollable caught our post fling and flung
1548 assertThat(value).isGreaterThan(lastValueBeforeFling)
1549 }
1550 }
1551
1552 @Test
1553 fun scrollable_nestedScroll_allowParentWhenDisabled() {
1554 var childValue = 0f
1555 var parentValue = 0f
1556 val childController =
1557 ScrollableState(
1558 consumeScrollDelta = {
1559 childValue += it
1560 it
1561 }
1562 )
1563 val parentController =
1564 ScrollableState(
1565 consumeScrollDelta = {
1566 parentValue += it
1567 it
1568 }
1569 )
1570
1571 rule.setContentAndGetScope {
1572 Box {
1573 Box(
1574 modifier =
1575 Modifier.size(300.dp)
1576 .scrollable(
1577 state = parentController,
1578 orientation = Orientation.Horizontal
1579 )
1580 ) {
1581 Box(
1582 Modifier.size(200.dp)
1583 .testTag(scrollableBoxTag)
1584 .scrollable(
1585 enabled = false,
1586 orientation = Orientation.Horizontal,
1587 state = childController
1588 )
1589 )
1590 }
1591 }
1592 }
1593
1594 rule.runOnIdle {
1595 assertThat(parentValue).isEqualTo(0f)
1596 assertThat(childValue).isEqualTo(0f)
1597 }
1598
1599 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1600 swipe(center, center.copy(x = center.x + 100f))
1601 }
1602
1603 rule.runOnIdle {
1604 assertThat(childValue).isEqualTo(0f)
1605 assertThat(parentValue).isGreaterThan(0f)
1606 }
1607 }
1608
1609 @Test
1610 fun scrollable_nestedScroll_disabledConnectionNoOp() {
1611 var childValue = 0f
1612 var parentValue = 0f
1613 var selfValue = 0f
1614 val childController =
1615 ScrollableState(
1616 consumeScrollDelta = {
1617 childValue += it / 2
1618 it / 2
1619 }
1620 )
1621 val middleController =
1622 ScrollableState(
1623 consumeScrollDelta = {
1624 selfValue += it / 2
1625 it / 2
1626 }
1627 )
1628 val parentController =
1629 ScrollableState(
1630 consumeScrollDelta = {
1631 parentValue += it / 2
1632 it / 2
1633 }
1634 )
1635
1636 rule.setContentAndGetScope {
1637 Box {
1638 Box(
1639 modifier =
1640 Modifier.size(300.dp)
1641 .scrollable(
1642 state = parentController,
1643 orientation = Orientation.Horizontal
1644 )
1645 ) {
1646 Box(
1647 Modifier.size(200.dp)
1648 .scrollable(
1649 enabled = false,
1650 orientation = Orientation.Horizontal,
1651 state = middleController
1652 )
1653 ) {
1654 Box(
1655 Modifier.size(200.dp)
1656 .testTag(scrollableBoxTag)
1657 .scrollable(
1658 orientation = Orientation.Horizontal,
1659 state = childController
1660 )
1661 )
1662 }
1663 }
1664 }
1665 }
1666
1667 rule.runOnIdle {
1668 assertThat(parentValue).isEqualTo(0f)
1669 assertThat(selfValue).isEqualTo(0f)
1670 assertThat(childValue).isEqualTo(0f)
1671 }
1672
1673 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1674 swipe(center, center.copy(x = center.x + 100f))
1675 }
1676
1677 rule.runOnIdle {
1678 assertThat(childValue).isGreaterThan(0f)
1679 // disabled middle node doesn't consume
1680 assertThat(selfValue).isEqualTo(0f)
1681 // but allow nested scroll to propagate up correctly
1682 assertThat(parentValue).isGreaterThan(0f)
1683 }
1684 }
1685
1686 @Test
1687 fun scrollable_nestedFlingCancellation_shouldPreventDeltasFromPropagating() {
1688 var childDeltas = 0f
1689 var touchSlop = 0f
1690 val childController = ScrollableState {
1691 childDeltas += it
1692 it
1693 }
1694 val flingCancellationParent =
1695 object : NestedScrollConnection {
1696 override fun onPostScroll(
1697 consumed: Offset,
1698 available: Offset,
1699 source: NestedScrollSource
1700 ): Offset {
1701 if (source == NestedScrollSource.SideEffect && available != Offset.Zero) {
1702 throw CancellationException()
1703 }
1704 return Offset.Zero
1705 }
1706 }
1707
1708 rule.setContent {
1709 touchSlop = LocalViewConfiguration.current.touchSlop
1710 Box(modifier = Modifier.nestedScroll(flingCancellationParent)) {
1711 Box(
1712 modifier =
1713 Modifier.size(600.dp)
1714 .testTag("childScrollable")
1715 .scrollable(childController, Orientation.Horizontal)
1716 )
1717 }
1718 }
1719
1720 // First drag, this won't trigger the cancellation flow.
1721 rule.onNodeWithTag("childScrollable").performTouchInput {
1722 down(centerLeft)
1723 moveBy(Offset(100f, 0f))
1724 up()
1725 }
1726
1727 rule.runOnIdle { assertThat(childDeltas).isEqualTo(100f - touchSlop) }
1728
1729 childDeltas = 0f
1730 var dragged = 0f
1731 rule.onNodeWithTag("childScrollable").performTouchInput {
1732 swipeWithVelocity(centerLeft, centerRight, 2000f)
1733 dragged = centerRight.x - centerLeft.x
1734 }
1735
1736 // child didn't receive more deltas after drag, because fling was cancelled by the parent
1737 assertThat(childDeltas).isEqualTo(dragged - touchSlop)
1738 }
1739
1740 @Test
1741 fun scrollable_nestedFling_shouldCancelWhenHitTheBounds() {
1742 var latestAvailableVelocity = Velocity.Zero
1743 var onPostFlingCalled = false
1744 val connection =
1745 object : NestedScrollConnection {
1746 override suspend fun onPostFling(
1747 consumed: Velocity,
1748 available: Velocity
1749 ): Velocity {
1750 latestAvailableVelocity = available
1751 onPostFlingCalled = true
1752 return super.onPostFling(consumed, available)
1753 }
1754 }
1755 rule.setContent {
1756 Box(
1757 Modifier.scrollable(
1758 state = rememberScrollableState { it },
1759 orientation = Orientation.Vertical
1760 )
1761 ) {
1762 Box(Modifier.nestedScroll(connection)) {
1763 Column(
1764 Modifier.testTag("column")
1765 .verticalScroll(
1766 rememberScrollState(with(rule.density) { (5 * 200.dp).roundToPx() })
1767 )
1768 ) {
1769 repeat(10) { Box(Modifier.size(200.dp)) }
1770 }
1771 }
1772 }
1773 }
1774
1775 rule.onNodeWithTag("column").performTouchInput { swipeDown() }
1776
1777 /**
1778 * Because previously the animation was being completely consumed by the child fling, the
1779 * nested scroll connection in the middle would see a zero post fling velocity, even if the
1780 * child hit the bounds.
1781 */
1782 rule.runOnIdle {
1783 assertThat(onPostFlingCalled).isTrue()
1784 assertThat(latestAvailableVelocity.y).isNonZero()
1785 }
1786 }
1787
1788 @Test
1789 fun scrollable_nestedFling_parentShouldFlingWithVelocityLeft() {
1790 var postFlingCalled = false
1791 var lastPostFlingVelocity = Velocity.Zero
1792 var flingDelta = 0.0f
1793 val fling =
1794 object : FlingBehavior {
1795 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
1796 assertThat(initialVelocity).isEqualTo(lastPostFlingVelocity.y)
1797 scrollBy(100f)
1798 return initialVelocity
1799 }
1800 }
1801 val topConnection =
1802 object : NestedScrollConnection {
1803 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
1804 // accumulate deltas for second fling only
1805 if (source == NestedScrollSource.SideEffect && postFlingCalled) {
1806 flingDelta += available.y
1807 }
1808 return super.onPreScroll(available, source)
1809 }
1810 }
1811
1812 val middleConnection =
1813 object : NestedScrollConnection {
1814 override suspend fun onPostFling(
1815 consumed: Velocity,
1816 available: Velocity
1817 ): Velocity {
1818 postFlingCalled = true
1819 lastPostFlingVelocity = available
1820 return super.onPostFling(consumed, available)
1821 }
1822 }
1823 val columnState = ScrollState(with(rule.density) { (5 * 200.dp).roundToPx() })
1824 rule.setContent {
1825 Box(
1826 Modifier.nestedScroll(topConnection)
1827 .scrollable(
1828 flingBehavior = fling,
1829 state = rememberScrollableState { it },
1830 orientation = Orientation.Vertical
1831 )
1832 ) {
1833 Column(
1834 Modifier.nestedScroll(middleConnection)
1835 .testTag("column")
1836 .verticalScroll(columnState)
1837 ) {
1838 repeat(10) { Box(Modifier.size(200.dp)) }
1839 }
1840 }
1841 }
1842
1843 rule.onNodeWithTag("column").performTouchInput { swipeDown() }
1844
1845 rule.runOnIdle {
1846 assertThat(columnState.value).isZero() // column is at the bounds
1847 assertThat(postFlingCalled)
1848 .isTrue() // we fired a post fling call after the cancellation
1849 assertThat(lastPostFlingVelocity.y)
1850 .isNonZero() // the post child fling velocity was not zero
1851 assertThat(flingDelta).isEqualTo(100f) // the fling delta as propagated correctly
1852 }
1853 }
1854
1855 @Test
1856 fun scrollable_nestedFling_parentShouldFlingWithVelocityLeft_whenInnerDisappears() {
1857 var postFlingCalled = false
1858 var postFlingAvailableVelocity = Velocity.Zero
1859 var postFlingConsumedVelocity = Velocity.Zero
1860 var flingDelta by mutableFloatStateOf(0.0f)
1861 var preFlingVelocity = Velocity.Zero
1862
1863 val topConnection =
1864 object : NestedScrollConnection {
1865 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
1866 // accumulate deltas for second fling only
1867 if (source == NestedScrollSource.SideEffect) {
1868 flingDelta += available.y
1869 }
1870 return super.onPreScroll(available, source)
1871 }
1872
1873 override suspend fun onPreFling(available: Velocity): Velocity {
1874 preFlingVelocity = available
1875 return super.onPreFling(available)
1876 }
1877
1878 override suspend fun onPostFling(
1879 consumed: Velocity,
1880 available: Velocity
1881 ): Velocity {
1882 postFlingCalled = true
1883 postFlingAvailableVelocity = available
1884 postFlingConsumedVelocity = consumed
1885 return super.onPostFling(consumed, available)
1886 }
1887 }
1888
1889 val columnState = ScrollState(with(rule.density) { (50 * 200.dp).roundToPx() })
1890
1891 rule.setContent {
1892 Box(Modifier.nestedScroll(topConnection)) {
1893 if (flingDelta.absoluteValue < 100) {
1894 Column(Modifier.testTag("column").verticalScroll(columnState)) {
1895 repeat(100) { Box(Modifier.size(200.dp)) }
1896 }
1897 }
1898 }
1899 }
1900
1901 rule.onNodeWithTag("column").performTouchInput { swipeUp() }
1902 rule.waitForIdle()
1903 // removed scrollable
1904 rule.onNodeWithTag("column").assertDoesNotExist()
1905 rule.runOnIdle {
1906 // we fired a post fling call after the disappearance
1907 assertThat(postFlingCalled).isTrue()
1908
1909 // fling velocity in onPostFling is correctly propagated
1910 assertThat(postFlingConsumedVelocity + postFlingAvailableVelocity)
1911 .isEqualTo(preFlingVelocity)
1912 }
1913 }
1914
1915 @Test
1916 fun scrollable_bothOrientations_proxiesPostFling() {
1917 val velocityFlung = 5000f
1918 val outerState = ScrollableState(consumeScrollDelta = { 0f })
1919 val innerState = ScrollableState(consumeScrollDelta = { 0f })
1920 val innerFlingBehavior =
1921 object : FlingBehavior {
1922 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
1923 return initialVelocity
1924 }
1925 }
1926 val parent =
1927 object : NestedScrollConnection {
1928 override suspend fun onPostFling(
1929 consumed: Velocity,
1930 available: Velocity
1931 ): Velocity {
1932 assertThat(consumed.x).isEqualTo(0f)
1933 assertThat(available.x).isWithin(0.1f).of(velocityFlung)
1934 return available
1935 }
1936 }
1937
1938 rule.setContentAndGetScope {
1939 Box {
1940 Box(
1941 contentAlignment = Alignment.Center,
1942 modifier =
1943 Modifier.size(300.dp)
1944 .nestedScroll(parent)
1945 .scrollable(state = outerState, orientation = Orientation.Vertical)
1946 ) {
1947 Box(
1948 modifier =
1949 Modifier.size(300.dp)
1950 .testTag(scrollableBoxTag)
1951 .scrollable(
1952 state = innerState,
1953 flingBehavior = innerFlingBehavior,
1954 orientation = Orientation.Horizontal
1955 )
1956 )
1957 }
1958 }
1959 }
1960
1961 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
1962 this.swipeWithVelocity(
1963 start = this.center,
1964 end = Offset(this.center.x + 500f, this.center.y),
1965 durationMillis = 300,
1966 endVelocity = velocityFlung
1967 )
1968 }
1969
1970 // all assertions in callback above
1971 rule.waitForIdle()
1972 }
1973
1974 @Test
1975 fun scrollable_interactionSource() {
1976 val interactionSource = MutableInteractionSource()
1977 var total = 0f
1978 val controller =
1979 ScrollableState(
1980 consumeScrollDelta = {
1981 total += it
1982 it
1983 }
1984 )
1985
1986 setScrollableContent {
1987 Modifier.scrollable(
1988 interactionSource = interactionSource,
1989 orientation = Orientation.Horizontal,
1990 state = controller
1991 )
1992 }
1993
1994 val interactions = mutableListOf<Interaction>()
1995
1996 scope.launch { interactionSource.interactions.collect { interactions.add(it) } }
1997
1998 rule.runOnIdle { assertThat(interactions).isEmpty() }
1999
2000 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2001 down(Offset(visibleSize.width / 4f, visibleSize.height / 2f))
2002 moveBy(Offset(visibleSize.width / 2f, 0f))
2003 }
2004
2005 rule.runOnIdle {
2006 assertThat(interactions).hasSize(1)
2007 assertThat(interactions.first()).isInstanceOf(DragInteraction.Start::class.java)
2008 }
2009
2010 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { up() }
2011
2012 rule.runOnIdle {
2013 assertThat(interactions).hasSize(2)
2014 assertThat(interactions.first()).isInstanceOf(DragInteraction.Start::class.java)
2015 assertThat(interactions[1]).isInstanceOf(DragInteraction.Stop::class.java)
2016 assertThat((interactions[1] as DragInteraction.Stop).start).isEqualTo(interactions[0])
2017 }
2018 }
2019
2020 @Test
2021 fun scrollable_interactionSource_resetWhenDisposed() {
2022 val interactionSource = MutableInteractionSource()
2023 var emitScrollableBox by mutableStateOf(true)
2024 var total = 0f
2025 val controller =
2026 ScrollableState(
2027 consumeScrollDelta = {
2028 total += it
2029 it
2030 }
2031 )
2032
2033 rule.setContentAndGetScope {
2034 Box {
2035 if (emitScrollableBox) {
2036 Box(
2037 modifier =
2038 Modifier.testTag(scrollableBoxTag)
2039 .size(100.dp)
2040 .scrollable(
2041 interactionSource = interactionSource,
2042 orientation = Orientation.Horizontal,
2043 state = controller
2044 )
2045 )
2046 }
2047 }
2048 }
2049
2050 val interactions = mutableListOf<Interaction>()
2051
2052 scope.launch { interactionSource.interactions.collect { interactions.add(it) } }
2053
2054 rule.runOnIdle { assertThat(interactions).isEmpty() }
2055
2056 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2057 down(Offset(visibleSize.width / 4f, visibleSize.height / 2f))
2058 moveBy(Offset(visibleSize.width / 2f, 0f))
2059 }
2060
2061 rule.runOnIdle {
2062 assertThat(interactions).hasSize(1)
2063 assertThat(interactions.first()).isInstanceOf(DragInteraction.Start::class.java)
2064 }
2065
2066 // Dispose scrollable
2067 rule.runOnIdle { emitScrollableBox = false }
2068
2069 rule.runOnIdle {
2070 assertThat(interactions).hasSize(2)
2071 assertThat(interactions.first()).isInstanceOf(DragInteraction.Start::class.java)
2072 assertThat(interactions[1]).isInstanceOf(DragInteraction.Cancel::class.java)
2073 assertThat((interactions[1] as DragInteraction.Cancel).start).isEqualTo(interactions[0])
2074 }
2075 }
2076
2077 @Test
2078 fun scrollable_flingBehaviourCalled_whenVelocity0() {
2079 var total = 0f
2080 val controller =
2081 ScrollableState(
2082 consumeScrollDelta = {
2083 total += it
2084 it
2085 }
2086 )
2087 var flingCalled = 0
2088 var flingVelocity: Float = Float.MAX_VALUE
2089 val flingBehaviour =
2090 object : FlingBehavior {
2091 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
2092 flingCalled++
2093 flingVelocity = initialVelocity
2094 return 0f
2095 }
2096 }
2097 setScrollableContent {
2098 Modifier.scrollable(
2099 state = controller,
2100 flingBehavior = flingBehaviour,
2101 orientation = Orientation.Horizontal
2102 )
2103 }
2104 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2105 down(this.center)
2106 moveBy(Offset(115f, 0f))
2107 up()
2108 }
2109 assertThat(flingCalled).isEqualTo(1)
2110 assertThat(flingVelocity).isLessThan(0.01f)
2111 assertThat(flingVelocity).isGreaterThan(-0.01f)
2112 }
2113
2114 @Test
2115 fun scrollable_flingBehaviourCalled() {
2116 var total = 0f
2117 val controller =
2118 ScrollableState(
2119 consumeScrollDelta = {
2120 total += it
2121 it
2122 }
2123 )
2124 var flingCalled = 0
2125 var flingVelocity: Float = Float.MAX_VALUE
2126 val flingBehaviour =
2127 object : FlingBehavior {
2128 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
2129 flingCalled++
2130 flingVelocity = initialVelocity
2131 return 0f
2132 }
2133 }
2134 setScrollableContent {
2135 Modifier.scrollable(
2136 state = controller,
2137 flingBehavior = flingBehaviour,
2138 orientation = Orientation.Horizontal
2139 )
2140 }
2141 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2142 swipeWithVelocity(this.center, this.center + Offset(115f, 0f), endVelocity = 1000f)
2143 }
2144 assertThat(flingCalled).isEqualTo(1)
2145 assertThat(flingVelocity).isWithin(5f).of(1000f)
2146 }
2147
2148 @Test
2149 fun scrollable_flingBehaviourCalled_reversed() {
2150 var total = 0f
2151 val controller =
2152 ScrollableState(
2153 consumeScrollDelta = {
2154 total += it
2155 it
2156 }
2157 )
2158 var flingCalled = 0
2159 var flingVelocity: Float = Float.MAX_VALUE
2160 val flingBehaviour =
2161 object : FlingBehavior {
2162 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
2163 flingCalled++
2164 flingVelocity = initialVelocity
2165 return 0f
2166 }
2167 }
2168 setScrollableContent {
2169 Modifier.scrollable(
2170 state = controller,
2171 reverseDirection = true,
2172 flingBehavior = flingBehaviour,
2173 orientation = Orientation.Horizontal
2174 )
2175 }
2176 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2177 swipeWithVelocity(this.center, this.center + Offset(115f, 0f), endVelocity = 1000f)
2178 }
2179 assertThat(flingCalled).isEqualTo(1)
2180 assertThat(flingVelocity).isWithin(5f).of(-1000f)
2181 }
2182
2183 @Test
2184 fun scrollable_flingBehaviourCalled_correctScope() {
2185 var total = 0f
2186 var returned = 0f
2187 val controller =
2188 ScrollableState(
2189 consumeScrollDelta = {
2190 total += it
2191 it
2192 }
2193 )
2194 val flingBehaviour =
2195 object : FlingBehavior {
2196 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
2197 returned = scrollBy(123f)
2198 return 0f
2199 }
2200 }
2201 setScrollableContent {
2202 Modifier.scrollable(
2203 state = controller,
2204 flingBehavior = flingBehaviour,
2205 orientation = Orientation.Horizontal
2206 )
2207 }
2208 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2209 down(center)
2210 moveBy(Offset(x = 100f, y = 0f))
2211 }
2212
2213 val prevTotal =
2214 rule.runOnIdle {
2215 assertThat(total).isGreaterThan(0f)
2216 total
2217 }
2218
2219 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { up() }
2220
2221 rule.runOnIdle {
2222 assertThat(total).isEqualTo(prevTotal + 123)
2223 assertThat(returned).isEqualTo(123f)
2224 }
2225 }
2226
2227 @Test
2228 fun scrollable_flingBehaviourCalled_reversed_correctScope() {
2229 var total = 0f
2230 var returned = 0f
2231 val controller =
2232 ScrollableState(
2233 consumeScrollDelta = {
2234 total += it
2235 it
2236 }
2237 )
2238 val flingBehaviour =
2239 object : FlingBehavior {
2240 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
2241 returned = scrollBy(123f)
2242 return 0f
2243 }
2244 }
2245 setScrollableContent {
2246 Modifier.scrollable(
2247 state = controller,
2248 reverseDirection = true,
2249 flingBehavior = flingBehaviour,
2250 orientation = Orientation.Horizontal
2251 )
2252 }
2253 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
2254 down(center)
2255 moveBy(Offset(x = 100f, y = 0f))
2256 }
2257
2258 val prevTotal =
2259 rule.runOnIdle {
2260 assertThat(total).isLessThan(0f)
2261 total
2262 }
2263
2264 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { up() }
2265
2266 rule.runOnIdle {
2267 assertThat(total).isEqualTo(prevTotal + 123)
2268 assertThat(returned).isEqualTo(123f)
2269 }
2270 }
2271
2272 @Test
2273 fun scrollable_setsModifierLocalScrollableContainer() {
2274 val controller = ScrollableState { it }
2275
2276 var isOuterInScrollableContainer: Boolean? = null
2277 var isInnerInScrollableContainer: Boolean? = null
2278 rule.setContent {
2279 Box {
2280 Box(
2281 modifier =
2282 Modifier.testTag(scrollableBoxTag)
2283 .size(100.dp)
2284 .then(
2285 ScrollableContainerReaderNodeElement {
2286 isOuterInScrollableContainer = it
2287 }
2288 )
2289 .scrollable(state = controller, orientation = Orientation.Horizontal)
2290 .then(
2291 ScrollableContainerReaderNodeElement {
2292 isInnerInScrollableContainer = it
2293 }
2294 )
2295 )
2296 }
2297 }
2298
2299 rule.runOnIdle {
2300 assertThat(isOuterInScrollableContainer).isFalse()
2301 assertThat(isInnerInScrollableContainer).isTrue()
2302 }
2303 }
2304
2305 @Test
2306 fun scrollable_setsModifierLocalScrollableContainer_scrollDisabled() {
2307 val controller = ScrollableState { it }
2308
2309 var isOuterInScrollableContainer: Boolean? = null
2310 var isInnerInScrollableContainer: Boolean? = null
2311 rule.setContent {
2312 Box {
2313 Box(
2314 modifier =
2315 Modifier.testTag(scrollableBoxTag)
2316 .size(100.dp)
2317 .then(
2318 ScrollableContainerReaderNodeElement {
2319 isOuterInScrollableContainer = it
2320 }
2321 )
2322 .scrollable(
2323 state = controller,
2324 orientation = Orientation.Horizontal,
2325 enabled = false
2326 )
2327 .then(
2328 ScrollableContainerReaderNodeElement {
2329 isInnerInScrollableContainer = it
2330 }
2331 )
2332 )
2333 }
2334 }
2335
2336 rule.runOnIdle {
2337 assertThat(isOuterInScrollableContainer).isFalse()
2338 assertThat(isInnerInScrollableContainer).isFalse()
2339 }
2340 }
2341
2342 @Test
2343 fun scrollable_setsModifierLocalScrollableContainer_scrollUpdates() {
2344 val controller = ScrollableState { it }
2345
2346 var isInnerInScrollableContainer: Boolean? = null
2347 val enabled = mutableStateOf(true)
2348 rule.setContent {
2349 Box {
2350 Box(
2351 modifier =
2352 Modifier.testTag(scrollableBoxTag)
2353 .size(100.dp)
2354 .scrollable(
2355 state = controller,
2356 orientation = Orientation.Horizontal,
2357 enabled = enabled.value
2358 )
2359 .then(
2360 ScrollableContainerReaderNodeElement {
2361 isInnerInScrollableContainer = it
2362 }
2363 )
2364 )
2365 }
2366 }
2367
2368 rule.runOnIdle { assertThat(isInnerInScrollableContainer).isTrue() }
2369
2370 rule.runOnIdle { enabled.value = false }
2371
2372 rule.runOnIdle { assertThat(isInnerInScrollableContainer).isFalse() }
2373 }
2374
2375 @Test
2376 fun scrollable_scrollByWorksWithRepeatableAnimations() {
2377 rule.mainClock.autoAdvance = false
2378
2379 var total = 0f
2380 val controller =
2381 ScrollableState(
2382 consumeScrollDelta = {
2383 total += it
2384 it
2385 }
2386 )
2387 rule.setContentAndGetScope {
2388 Box(
2389 modifier =
2390 Modifier.size(100.dp)
2391 .scrollable(state = controller, orientation = Orientation.Horizontal)
2392 )
2393 }
2394
2395 rule.runOnIdle {
2396 scope.launch {
2397 controller.animateScrollBy(
2398 100f,
2399 keyframes {
2400 durationMillis = 2500
2401 // emulate a repeatable animation:
2402 0f at 0
2403 100f at 500
2404 100f at 1000
2405 0f at 1500
2406 0f at 2000
2407 100f at 2500
2408 }
2409 )
2410 }
2411 }
2412
2413 rule.mainClock.advanceTimeBy(250)
2414 rule.runOnIdle {
2415 // in the middle of the first animation
2416 assertThat(total).isGreaterThan(0f)
2417 assertThat(total).isLessThan(100f)
2418 }
2419
2420 rule.mainClock.advanceTimeBy(500) // 750 ms
2421 rule.runOnIdle {
2422 // first animation finished
2423 assertThat(total).isEqualTo(100)
2424 }
2425
2426 rule.mainClock.advanceTimeBy(250) // 1250 ms
2427 rule.runOnIdle {
2428 // in the middle of the second animation
2429 assertThat(total).isGreaterThan(0f)
2430 assertThat(total).isLessThan(100f)
2431 }
2432
2433 rule.mainClock.advanceTimeBy(500) // 1750 ms
2434 rule.runOnIdle {
2435 // second animation finished
2436 assertThat(total).isEqualTo(0)
2437 }
2438
2439 rule.mainClock.advanceTimeBy(500) // 2250 ms
2440 rule.runOnIdle {
2441 // in the middle of the third animation
2442 assertThat(total).isGreaterThan(0f)
2443 assertThat(total).isLessThan(100f)
2444 }
2445
2446 rule.mainClock.advanceTimeBy(500) // 2750 ms
2447 rule.runOnIdle {
2448 // third animation finished
2449 assertThat(total).isEqualTo(100)
2450 }
2451 }
2452
2453 @Test
2454 fun scrollable_cancellingAnimateScrollUpdatesIsScrollInProgress() {
2455 rule.mainClock.autoAdvance = false
2456
2457 var total = 0f
2458 val controller =
2459 ScrollableState(
2460 consumeScrollDelta = {
2461 total += it
2462 it
2463 }
2464 )
2465 rule.setContentAndGetScope {
2466 Box(
2467 modifier =
2468 Modifier.size(100.dp)
2469 .scrollable(state = controller, orientation = Orientation.Horizontal)
2470 )
2471 }
2472
2473 lateinit var animateJob: Job
2474
2475 rule.runOnIdle {
2476 animateJob = scope.launch { controller.animateScrollBy(100f, tween(1000)) }
2477 }
2478
2479 rule.mainClock.advanceTimeBy(500)
2480 rule.runOnIdle { assertThat(controller.isScrollInProgress).isTrue() }
2481
2482 // Stop halfway through the animation
2483 animateJob.cancel()
2484
2485 rule.runOnIdle { assertThat(controller.isScrollInProgress).isFalse() }
2486 }
2487
2488 @Test
2489 fun scrollable_preemptingAnimateScrollUpdatesIsScrollInProgress() {
2490 rule.mainClock.autoAdvance = false
2491
2492 var total = 0f
2493 val controller =
2494 ScrollableState(
2495 consumeScrollDelta = {
2496 total += it
2497 it
2498 }
2499 )
2500 rule.setContentAndGetScope {
2501 Box(
2502 modifier =
2503 Modifier.size(100.dp)
2504 .scrollable(state = controller, orientation = Orientation.Horizontal)
2505 )
2506 }
2507
2508 rule.runOnIdle { scope.launch { controller.animateScrollBy(100f, tween(1000)) } }
2509
2510 rule.mainClock.advanceTimeBy(500)
2511 rule.runOnIdle {
2512 assertThat(total).isGreaterThan(0f)
2513 assertThat(total).isLessThan(100f)
2514 assertThat(controller.isScrollInProgress).isTrue()
2515 scope.launch { controller.animateScrollBy(-100f, tween(1000)) }
2516 }
2517
2518 rule.runOnIdle { assertThat(controller.isScrollInProgress).isTrue() }
2519
2520 rule.mainClock.advanceTimeBy(1000)
2521 rule.mainClock.advanceTimeByFrame()
2522
2523 rule.runOnIdle {
2524 assertThat(total).isGreaterThan(-75f)
2525 assertThat(total).isLessThan(0f)
2526 assertThat(controller.isScrollInProgress).isFalse()
2527 }
2528 }
2529
2530 @Test
2531 fun scrollable_multiDirectionsShouldPropagateOrthogonalAxisToNextParentWithSameDirection() {
2532 var innerDelta = 0f
2533 var middleDelta = 0f
2534 var outerDelta = 0f
2535
2536 val outerStateController = ScrollableState {
2537 outerDelta += it
2538 it
2539 }
2540
2541 val middleController = ScrollableState {
2542 middleDelta += it
2543 it / 2
2544 }
2545
2546 val innerController = ScrollableState {
2547 innerDelta += it
2548 it / 2
2549 }
2550
2551 rule.setContentAndGetScope {
2552 Box(
2553 modifier =
2554 Modifier.testTag("outerScrollable")
2555 .size(300.dp)
2556 .scrollable(outerStateController, orientation = Orientation.Horizontal)
2557 ) {
2558 Box(
2559 modifier =
2560 Modifier.testTag("middleScrollable")
2561 .size(300.dp)
2562 .scrollable(middleController, orientation = Orientation.Vertical)
2563 ) {
2564 Box(
2565 modifier =
2566 Modifier.testTag("innerScrollable")
2567 .size(300.dp)
2568 .scrollable(innerController, orientation = Orientation.Horizontal)
2569 )
2570 }
2571 }
2572 }
2573
2574 rule.onNodeWithTag("innerScrollable").performTouchInput {
2575 down(center)
2576 moveBy(Offset(100f, 0f))
2577 up()
2578 }
2579
2580 rule.runOnIdle {
2581 assertThat(innerDelta).isGreaterThan(0)
2582 assertThat(middleDelta).isEqualTo(0)
2583 assertThat(outerDelta).isEqualTo(innerDelta / 2f)
2584 }
2585 }
2586
2587 @Test
2588 fun nestedScrollable_noFlingContinuationInCrossAxis_shouldAllowClicksOnCrossAxis_scrollable() {
2589 var clicked = 0
2590 rule.setContentAndGetScope {
2591 LazyColumn(Modifier.testTag("column")) {
2592 item {
2593 Box(
2594 modifier =
2595 Modifier.size(20.dp).background(Color.Red).clickable { clicked++ }
2596 )
2597 }
2598 item {
2599 LazyRow(Modifier.testTag("list")) {
2600 items(100) { Box(modifier = Modifier.size(20.dp).background(Color.Blue)) }
2601 }
2602 }
2603 }
2604 }
2605
2606 rule.mainClock.autoAdvance = false
2607 rule.onNodeWithTag("list", useUnmergedTree = true).performTouchInput { swipeLeft() }
2608
2609 rule.mainClock.advanceTimeByFrame()
2610 rule.mainClock.advanceTimeByFrame()
2611
2612 rule.onNodeWithTag("column").performTouchInput { click(Offset(10f, 10f)) }
2613
2614 rule.mainClock.autoAdvance = true
2615
2616 rule.runOnIdle { assertThat(clicked).isEqualTo(1) }
2617 }
2618
2619 // b/179417109 Double checks that in a nested scroll cycle, the parent post scroll
2620 // consumption is taken into consideration.
2621 @Test
2622 fun dispatchScroll_shouldReturnConsumedDeltaInNestedScrollChain() {
2623 var consumedInner = 0f
2624 var consumedOuter = 0f
2625 var touchSlop = 0f
2626
2627 var preScrollAvailable = Offset.Zero
2628 var consumedPostScroll = Offset.Zero
2629 var postScrollAvailable = Offset.Zero
2630
2631 val outerStateController = ScrollableState {
2632 consumedOuter += it
2633 it
2634 }
2635
2636 val innerController = ScrollableState {
2637 consumedInner += it / 2
2638 it / 2
2639 }
2640
2641 val connection =
2642 object : NestedScrollConnection {
2643 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
2644 preScrollAvailable += available
2645 return Offset.Zero
2646 }
2647
2648 override fun onPostScroll(
2649 consumed: Offset,
2650 available: Offset,
2651 source: NestedScrollSource
2652 ): Offset {
2653 consumedPostScroll += consumed
2654 postScrollAvailable += available
2655 return Offset.Zero
2656 }
2657 }
2658
2659 rule.setContent {
2660 touchSlop = LocalViewConfiguration.current.touchSlop
2661 Box(modifier = Modifier.nestedScroll(connection)) {
2662 Box(
2663 modifier =
2664 Modifier.testTag("outerScrollable")
2665 .size(300.dp)
2666 .scrollable(outerStateController, orientation = Orientation.Horizontal)
2667 ) {
2668 Box(
2669 modifier =
2670 Modifier.testTag("innerScrollable")
2671 .size(300.dp)
2672 .scrollable(innerController, orientation = Orientation.Horizontal)
2673 )
2674 }
2675 }
2676 }
2677
2678 val scrollDelta = 200f
2679
2680 rule.onRoot().performTouchInput {
2681 down(center)
2682 moveBy(Offset(scrollDelta, 0f))
2683 up()
2684 }
2685
2686 rule.runOnIdle {
2687 assertThat(consumedInner).isGreaterThan(0)
2688 assertThat(consumedOuter).isGreaterThan(0)
2689 assertThat(touchSlop).isGreaterThan(0)
2690 assertThat(postScrollAvailable.x).isEqualTo(0f)
2691 assertThat(consumedPostScroll.x).isEqualTo(scrollDelta - touchSlop)
2692 assertThat(preScrollAvailable.x).isEqualTo(scrollDelta - touchSlop)
2693 assertThat(scrollDelta).isEqualTo(consumedInner + consumedOuter + touchSlop)
2694 }
2695 }
2696
2697 @Test
2698 fun testInspectorValue() {
2699 val controller = ScrollableState(consumeScrollDelta = { it })
2700 rule.setContentAndGetScope {
2701 val modifier =
2702 Modifier.scrollable(controller, Orientation.Vertical).first() as InspectableValue
2703 assertThat(modifier.nameFallback).isEqualTo("scrollable")
2704 assertThat(modifier.valueOverride).isNull()
2705 assertThat(modifier.inspectableElements.map { it.name }.asIterable())
2706 .containsExactly(
2707 "orientation",
2708 "state",
2709 "overscrollEffect",
2710 "enabled",
2711 "reverseDirection",
2712 "flingBehavior",
2713 "interactionSource",
2714 "bringIntoViewSpec",
2715 )
2716 }
2717 }
2718
2719 @Test
2720 fun producingEqualMaterializedModifierAfterRecomposition() {
2721 val state = ScrollableState { it }
2722 val counter = mutableStateOf(0)
2723 var materialized: Modifier? = null
2724
2725 rule.setContent {
2726 counter.value // just to trigger recomposition
2727 materialized =
2728 currentComposer.materialize(Modifier.scrollable(state, Orientation.Vertical, null))
2729 }
2730
2731 lateinit var first: Modifier
2732 rule.runOnIdle {
2733 first = requireNotNull(materialized)
2734 materialized = null
2735 counter.value++
2736 }
2737
2738 rule.runOnIdle {
2739 val second = requireNotNull(materialized)
2740 assertThat(first).isEqualTo(second)
2741 }
2742 }
2743
2744 @Test
2745 fun focusStaysInScrollableEvenThoughThereIsACloserItemOutside() {
2746 lateinit var focusManager: FocusManager
2747 val initialFocus = FocusRequester()
2748 var nextItemIsFocused = false
2749 rule.setContent {
2750 focusManager = LocalFocusManager.current
2751 Column {
2752 Column(Modifier.size(10.dp).verticalScroll(rememberScrollState())) {
2753 Box(Modifier.size(10.dp).focusRequester(initialFocus).focusable())
2754 Box(Modifier.size(10.dp))
2755 Box(
2756 Modifier.size(10.dp)
2757 .onFocusChanged { nextItemIsFocused = it.isFocused }
2758 .focusable()
2759 )
2760 }
2761 Box(Modifier.size(10.dp).focusable())
2762 }
2763 }
2764
2765 rule.runOnIdle { initialFocus.requestFocus() }
2766 rule.runOnIdle { focusManager.moveFocus(FocusDirection.Down) }
2767
2768 rule.runOnIdle { assertThat(nextItemIsFocused).isTrue() }
2769 }
2770
2771 @Test
2772 fun verticalScrollable_assertVelocityCalculationIsSimilarInsideOutsideVelocityTracker() {
2773 // arrange
2774 val tracker = VelocityTracker()
2775 var velocity = Velocity.Zero
2776 val capturingScrollConnection =
2777 object : NestedScrollConnection {
2778 override suspend fun onPreFling(available: Velocity): Velocity {
2779 velocity += available
2780 return Velocity.Zero
2781 }
2782 }
2783 val controller = ScrollableState { _ -> 0f }
2784
2785 setScrollableContent {
2786 Modifier.pointerInput(Unit) { savePointerInputEvents(tracker, this) }
2787 .nestedScroll(capturingScrollConnection)
2788 .scrollable(controller, Orientation.Vertical)
2789 }
2790
2791 // act
2792 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() }
2793
2794 // assert
2795 rule.runOnIdle {
2796 val diff = abs((velocity - tracker.calculateVelocity()).y)
2797 assertThat(diff).isLessThan(VelocityTrackerCalculationThreshold)
2798 }
2799 tracker.resetTracking()
2800 velocity = Velocity.Zero
2801
2802 // act
2803 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeDown() }
2804
2805 // assert
2806 rule.runOnIdle {
2807 val diff = abs((velocity - tracker.calculateVelocity()).y)
2808 assertThat(diff).isLessThan(VelocityTrackerCalculationThreshold)
2809 }
2810 }
2811
2812 @Test
2813 fun horizontalScrollable_assertVelocityCalculationIsSimilarInsideOutsideVelocityTracker() {
2814 // arrange
2815 val tracker = VelocityTracker()
2816 var velocity = Velocity.Zero
2817 val capturingScrollConnection =
2818 object : NestedScrollConnection {
2819 override suspend fun onPreFling(available: Velocity): Velocity {
2820 velocity += available
2821 return Velocity.Zero
2822 }
2823 }
2824 val controller = ScrollableState { _ -> 0f }
2825
2826 setScrollableContent {
2827 Modifier.pointerInput(Unit) { savePointerInputEvents(tracker, this) }
2828 .nestedScroll(capturingScrollConnection)
2829 .scrollable(controller, Orientation.Horizontal)
2830 }
2831
2832 // act
2833 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeLeft() }
2834
2835 // assert
2836 rule.runOnIdle {
2837 val diff = abs((velocity - tracker.calculateVelocity()).x)
2838 assertThat(diff).isLessThan(VelocityTrackerCalculationThreshold)
2839 }
2840 tracker.resetTracking()
2841 velocity = Velocity.Zero
2842
2843 // act
2844 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeRight() }
2845
2846 // assert
2847 rule.runOnIdle {
2848 val diff = abs((velocity - tracker.calculateVelocity()).x)
2849 assertThat(diff).isLessThan(VelocityTrackerCalculationThreshold)
2850 }
2851 }
2852
2853 @Test
2854 fun offsetsScrollable_velocityCalculationShouldConsiderLocalPositions() {
2855 // arrange
2856 var velocity = Velocity.Zero
2857 val fullScreen = mutableStateOf(false)
2858 lateinit var scrollState: LazyListState
2859 val capturingScrollConnection =
2860 object : NestedScrollConnection {
2861 override suspend fun onPreFling(available: Velocity): Velocity {
2862 velocity += available
2863 return Velocity.Zero
2864 }
2865 }
2866 rule.setContent {
2867 scrollState = rememberLazyListState()
2868 Column(modifier = Modifier.nestedScroll(capturingScrollConnection)) {
2869 if (!fullScreen.value) {
2870 Box(modifier = Modifier.fillMaxWidth().background(Color.Black).height(400.dp))
2871 }
2872
2873 LazyColumn(state = scrollState) {
2874 items(100) {
2875 Box(
2876 modifier =
2877 Modifier.padding(10.dp)
2878 .background(Color.Red)
2879 .fillMaxWidth()
2880 .height(50.dp)
2881 )
2882 }
2883 }
2884 }
2885 }
2886 // act
2887 // Register generated velocity with offset
2888 composeViewSwipeUp()
2889 rule.waitForIdle()
2890 val previousVelocity = velocity
2891 velocity = Velocity.Zero
2892 // Remove offset and restart scroll
2893 fullScreen.value = true
2894 rule.runOnIdle { runBlocking { scrollState.scrollToItem(0) } }
2895 rule.waitForIdle()
2896 // Register generated velocity without offset, should be larger as there was more
2897 // screen to cover.
2898 composeViewSwipeUp()
2899
2900 // assert
2901 rule.runOnIdle { assertThat(abs(previousVelocity.y)).isNotEqualTo(abs(velocity.y)) }
2902 }
2903
2904 @Test
2905 fun disableSystemAnimations_defaultFlingBehaviorShouldContinueToWork() {
2906
2907 val controller = ScrollableState { 0f }
2908 var defaultFlingBehavior: DefaultFlingBehavior? = null
2909 setScrollableContent {
2910 defaultFlingBehavior = ScrollableDefaults.flingBehavior() as? DefaultFlingBehavior
2911 Modifier.scrollable(
2912 state = controller,
2913 orientation = Orientation.Horizontal,
2914 flingBehavior = defaultFlingBehavior
2915 )
2916 }
2917
2918 scope.launch {
2919 controller.scroll { defaultFlingBehavior?.let { with(it) { performFling(1000f) } } }
2920 }
2921
2922 rule.runOnIdle {
2923 assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
2924 }
2925
2926 // Simulate turning of animation
2927 scope.launch {
2928 controller.scroll {
2929 withContext(TestScrollMotionDurationScale(0f)) {
2930 defaultFlingBehavior?.let { with(it) { performFling(1000f) } }
2931 }
2932 }
2933 }
2934
2935 rule.runOnIdle {
2936 assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
2937 }
2938 }
2939
2940 @Test
2941 fun defaultFlingBehavior_useScrollMotionDurationScale() {
2942
2943 val controller = ScrollableState { 0f }
2944 var defaultFlingBehavior: DefaultFlingBehavior? = null
2945 var switchMotionDurationScale by mutableStateOf(true)
2946
2947 rule.setContentAndGetScope {
2948 val flingSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
2949 if (switchMotionDurationScale) {
2950 defaultFlingBehavior =
2951 DefaultFlingBehavior(flingSpec, TestScrollMotionDurationScale(1f))
2952 Box(
2953 modifier =
2954 Modifier.testTag(scrollableBoxTag)
2955 .size(100.dp)
2956 .scrollable(
2957 state = controller,
2958 orientation = Orientation.Horizontal,
2959 flingBehavior = defaultFlingBehavior
2960 )
2961 )
2962 } else {
2963 defaultFlingBehavior =
2964 DefaultFlingBehavior(flingSpec, TestScrollMotionDurationScale(0f))
2965 Box(
2966 modifier =
2967 Modifier.testTag(scrollableBoxTag)
2968 .size(100.dp)
2969 .scrollable(
2970 state = controller,
2971 orientation = Orientation.Horizontal,
2972 flingBehavior = defaultFlingBehavior
2973 )
2974 )
2975 }
2976 }
2977
2978 scope.launch {
2979 controller.scroll { defaultFlingBehavior?.let { with(it) { performFling(1000f) } } }
2980 }
2981
2982 rule.runOnIdle {
2983 assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isGreaterThan(1)
2984 }
2985
2986 switchMotionDurationScale = false
2987 rule.waitForIdle()
2988
2989 scope.launch {
2990 controller.scroll { defaultFlingBehavior?.let { with(it) { performFling(1000f) } } }
2991 }
2992
2993 rule.runOnIdle { assertThat(defaultFlingBehavior?.lastAnimationCycleCount).isEqualTo(1) }
2994 }
2995
2996 @Test
2997 fun scrollable_noMomentum_shouldChangeScrollStateAfterRelease() {
2998 val scrollState = ScrollState(0)
2999 val delta = 10f
3000 var touchSlop = 0f
3001 setScrollableContent {
3002 touchSlop = LocalViewConfiguration.current.touchSlop
3003 Modifier.scrollable(scrollState, Orientation.Vertical)
3004 }
3005 var previousScrollValue = 0
3006 rule.onNodeWithTag(scrollableBoxTag).performTouchInput {
3007 down(center)
3008 // generate various move events
3009 repeat(30) {
3010 moveBy(Offset(0f, delta), delayMillis = 8L)
3011 previousScrollValue += delta.toInt()
3012 }
3013 // stop for a moment
3014 advanceEventTime(3000L)
3015 up()
3016 }
3017
3018 rule.runOnIdle {
3019 Assert.assertEquals((previousScrollValue - touchSlop).toInt(), scrollState.value)
3020 }
3021 }
3022
3023 @Test
3024 fun defaultScrollableState_scrollByWithNan_shouldFilterOutNan() {
3025 val controller = ScrollableState {
3026 assertThat(it).isNotNaN()
3027 0f
3028 }
3029
3030 val nanGenerator =
3031 object : FlingBehavior {
3032 override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
3033 return scrollBy(Float.NaN)
3034 }
3035 }
3036
3037 setScrollableContent {
3038 Modifier.scrollable(
3039 state = controller,
3040 orientation = Orientation.Horizontal,
3041 flingBehavior = nanGenerator
3042 )
3043 }
3044
3045 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeLeft() }
3046 }
3047
3048 @Test
3049 fun equalInputs_shouldResolveToEquals() {
3050 val state = ScrollableState { 0f }
3051
3052 assertModifierIsPure { toggleInput ->
3053 if (toggleInput) {
3054 Modifier.scrollable(state, Orientation.Horizontal)
3055 } else {
3056 Modifier.scrollable(state, Orientation.Vertical)
3057 }
3058 }
3059 }
3060
3061 @Test
3062 fun scrollableState_checkLastScrollDirection() {
3063 val controller = ScrollableState { it }
3064
3065 setScrollableContent {
3066 Modifier.scrollable(orientation = Orientation.Horizontal, state = controller)
3067 }
3068
3069 // Assert both isLastScrollForward and isLastScrollBackward are false before any scroll
3070 rule.runOnIdle {
3071 assertThat(controller.lastScrolledForward).isFalse()
3072 assertThat(controller.lastScrolledBackward).isFalse()
3073 }
3074
3075 lateinit var animateJob: Job
3076
3077 rule.runOnIdle {
3078 animateJob = scope.launch { controller.animateScrollBy(100f, tween(1000)) }
3079 }
3080
3081 rule.mainClock.advanceTimeBy(500)
3082
3083 // Assert isLastScrollForward is true during forward-scroll and isLastScrollBackward is
3084 // false
3085 rule.runOnIdle {
3086 assertThat(controller.lastScrolledForward).isTrue()
3087 assertThat(controller.lastScrolledBackward).isFalse()
3088 }
3089
3090 // Stop halfway through the animation
3091 animateJob.cancel()
3092
3093 // Assert isLastScrollForward is true after forward-scroll and isLastScrollBackward is false
3094 rule.runOnIdle {
3095 assertThat(controller.lastScrolledForward).isTrue()
3096 assertThat(controller.lastScrolledBackward).isFalse()
3097 }
3098
3099 rule.runOnIdle {
3100 animateJob = scope.launch { controller.animateScrollBy(-100f, tween(1000)) }
3101 }
3102
3103 rule.mainClock.advanceTimeBy(500)
3104
3105 // Assert isLastScrollForward is false during backward-scroll and isLastScrollBackward is
3106 // true
3107 rule.runOnIdle {
3108 assertThat(controller.lastScrolledForward).isFalse()
3109 assertThat(controller.lastScrolledBackward).isTrue()
3110 }
3111
3112 // Stop halfway through the animation
3113 animateJob.cancel()
3114
3115 // Assert isLastScrollForward is false after backward-scroll and isLastScrollBackward is
3116 // true
3117 rule.runOnIdle {
3118 assertThat(controller.lastScrolledForward).isFalse()
3119 assertThat(controller.lastScrolledBackward).isTrue()
3120 }
3121 }
3122
3123 @Test
3124 fun enabledChange_semanticsShouldBeCleared() {
3125 var enabled by mutableStateOf(true)
3126 rule.setContentAndGetScope {
3127 Box(
3128 modifier =
3129 Modifier.testTag(scrollableBoxTag)
3130 .size(100.dp)
3131 .scrollable(
3132 state = rememberScrollableState { it },
3133 orientation = Orientation.Horizontal,
3134 enabled = enabled
3135 )
3136 )
3137 }
3138
3139 rule
3140 .onNodeWithTag(scrollableBoxTag)
3141 .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy))
3142 rule
3143 .onNodeWithTag(scrollableBoxTag)
3144 .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollByOffset))
3145
3146 rule.runOnIdle { enabled = false }
3147
3148 rule
3149 .onNodeWithTag(scrollableBoxTag)
3150 .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollBy))
3151 rule
3152 .onNodeWithTag(scrollableBoxTag)
3153 .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollByOffset))
3154
3155 rule.runOnIdle { enabled = true }
3156
3157 rule
3158 .onNodeWithTag(scrollableBoxTag)
3159 .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy))
3160 rule
3161 .onNodeWithTag(scrollableBoxTag)
3162 .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollByOffset))
3163 }
3164
3165 @Test
3166 fun onDensityChange_shouldUpdateFlingBehavior() {
3167 var density by mutableStateOf(rule.density)
3168 var flingDelta = 0f
3169 val fixedSize = 400
3170 rule.setContent {
3171 CompositionLocalProvider(LocalDensity provides density) {
3172 Box(
3173 Modifier.size(with(density) { fixedSize.toDp() })
3174 .testTag(scrollableBoxTag)
3175 .scrollable(
3176 state =
3177 rememberScrollableState {
3178 flingDelta += it
3179 it
3180 },
3181 orientation = Orientation.Vertical
3182 )
3183 )
3184 }
3185 }
3186
3187 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() }
3188
3189 rule.waitForIdle()
3190
3191 density = Density(rule.density.density * 2f)
3192 val previousDelta = flingDelta
3193 flingDelta = 0.0f
3194
3195 rule.onNodeWithTag(scrollableBoxTag).performTouchInput { swipeUp() }
3196
3197 rule.runOnIdle { assertThat(flingDelta).isNotEqualTo(previousDelta) }
3198 }
3199
3200 @Test
3201 fun onNestedFlingCancelled_shouldResetFlingState() {
3202 rule.mainClock.autoAdvance = false
3203 var outerStateDeltas = 0f
3204 val outerState = ScrollableState {
3205 outerStateDeltas += it
3206 it
3207 }
3208
3209 val innerState = ScrollableState { it }
3210
3211 val dispatcher = NestedScrollDispatcher()
3212 var flingJob: Job? = null
3213
3214 rule.setContentAndGetScope {
3215 Box(
3216 Modifier.size(400.dp)
3217 .background(Color.Red)
3218 .scrollable(
3219 flingBehavior = ScrollableDefaults.flingBehavior(),
3220 state = outerState,
3221 orientation = Orientation.Vertical
3222 ),
3223 contentAlignment = Alignment.Center
3224 ) {
3225 Box(
3226 Modifier.size(200.dp)
3227 .background(Color.Black)
3228 .nestedScroll(
3229 connection = object : NestedScrollConnection {},
3230 dispatcher = dispatcher
3231 )
3232 .scrollable(state = innerState, orientation = Orientation.Vertical)
3233 )
3234 }
3235 }
3236
3237 rule.runOnIdle {
3238 // causes the inner scrollable to dispatch a post fling to the outer scrollable
3239 flingJob =
3240 scope.launch {
3241 innerState.scroll {
3242 dispatcher.dispatchPreFling(Velocity(0f, 10000f))
3243 dispatcher.dispatchPostFling(Velocity.Zero, Velocity(0f, 10000f))
3244 }
3245 }
3246 }
3247
3248 rule.mainClock.advanceTimeBy(200L)
3249
3250 rule.runOnIdle {
3251 // outer scrollable is flinging from onPostFling
3252 assertThat(outerStateDeltas).isNonZero()
3253 }
3254
3255 outerStateDeltas = 0f
3256
3257 rule.runOnIdle {
3258 flingJob?.cancel() // cancel job mid fling
3259
3260 // try to run fling again
3261 scope.launch {
3262 innerState.scroll {
3263 dispatcher.dispatchPreFling(Velocity(0f, 10000f))
3264 dispatcher.dispatchPostFling(Velocity.Zero, Velocity(0f, 10000f))
3265 }
3266 }
3267 }
3268
3269 rule.mainClock.autoAdvance = true
3270 // fling reached outer scrollable even if the previous child fling was cancelled.
3271 rule.runOnIdle {
3272 // outer scrollable is flinging from onPostFling
3273 assertThat(outerStateDeltas).isNonZero()
3274 }
3275 }
3276
3277 private fun setScrollableContent(scrollableModifierFactory: @Composable () -> Modifier) {
3278 rule.setContentAndGetScope {
3279 Box {
3280 val scrollable = scrollableModifierFactory()
3281 Box(modifier = Modifier.testTag(scrollableBoxTag).size(100.dp).then(scrollable))
3282 }
3283 }
3284 }
3285 }
3286
3287 // Very low tolerance on the difference
3288 internal val VelocityTrackerCalculationThreshold = 1
3289
3290 @OptIn(ExperimentalComposeUiApi::class)
savePointerInputEventsnull3291 internal suspend fun savePointerInputEvents(
3292 tracker: VelocityTracker,
3293 pointerInputScope: PointerInputScope
3294 ) {
3295 if (VelocityTrackerAddPointsFix) {
3296 savePointerInputEventsWithFix(tracker, pointerInputScope)
3297 } else {
3298 savePointerInputEventsLegacy(tracker, pointerInputScope)
3299 }
3300 }
3301
savePointerInputEventsWithFixnull3302 internal suspend fun savePointerInputEventsWithFix(
3303 tracker: VelocityTracker,
3304 pointerInputScope: PointerInputScope
3305 ) {
3306 with(pointerInputScope) {
3307 coroutineScope {
3308 awaitPointerEventScope {
3309 while (true) {
3310 var event: PointerInputChange? = awaitFirstDown()
3311 while (event != null && !event.changedToUpIgnoreConsumed()) {
3312 val currentEvent = awaitPointerEvent().changes.firstOrNull()
3313
3314 if (currentEvent != null && !currentEvent.changedToUpIgnoreConsumed()) {
3315 currentEvent.historical.fastForEach {
3316 tracker.addPosition(it.uptimeMillis, it.position)
3317 }
3318 tracker.addPosition(currentEvent.uptimeMillis, currentEvent.position)
3319 }
3320
3321 event = currentEvent
3322 }
3323 }
3324 }
3325 }
3326 }
3327 }
3328
savePointerInputEventsLegacynull3329 internal suspend fun savePointerInputEventsLegacy(
3330 tracker: VelocityTracker,
3331 pointerInputScope: PointerInputScope
3332 ) {
3333 with(pointerInputScope) {
3334 coroutineScope {
3335 awaitPointerEventScope {
3336 while (true) {
3337 var event = awaitFirstDown()
3338 tracker.addPosition(event.uptimeMillis, event.position)
3339 while (!event.changedToUpIgnoreConsumed()) {
3340 val currentEvent = awaitPointerEvent().changes.firstOrNull()
3341
3342 if (currentEvent != null) {
3343 currentEvent.historical.fastForEach {
3344 tracker.addPosition(it.uptimeMillis, it.position)
3345 }
3346 tracker.addPosition(currentEvent.uptimeMillis, currentEvent.position)
3347 event = currentEvent
3348 }
3349 }
3350 }
3351 }
3352 }
3353 }
3354 }
3355
composeViewSwipeUpnull3356 internal fun composeViewSwipeUp() {
3357 onView(allOf(instanceOf(AbstractComposeView::class.java)))
3358 .perform(espressoSwipe(GeneralLocation.CENTER, GeneralLocation.TOP_CENTER))
3359 }
3360
composeViewSwipeDownnull3361 internal fun composeViewSwipeDown() {
3362 onView(allOf(instanceOf(AbstractComposeView::class.java)))
3363 .perform(espressoSwipe(GeneralLocation.CENTER, GeneralLocation.BOTTOM_CENTER))
3364 }
3365
composeViewSwipeLeftnull3366 internal fun composeViewSwipeLeft() {
3367 onView(allOf(instanceOf(AbstractComposeView::class.java)))
3368 .perform(espressoSwipe(GeneralLocation.CENTER, GeneralLocation.CENTER_LEFT))
3369 }
3370
composeViewSwipeRightnull3371 internal fun composeViewSwipeRight() {
3372 onView(allOf(instanceOf(AbstractComposeView::class.java)))
3373 .perform(espressoSwipe(GeneralLocation.CENTER, GeneralLocation.CENTER_RIGHT))
3374 }
3375
espressoSwipenull3376 private fun espressoSwipe(
3377 start: CoordinatesProvider,
3378 end: CoordinatesProvider
3379 ): GeneralSwipeAction {
3380 return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
3381 }
3382
3383 internal class TestScrollMotionDurationScale(override val scaleFactor: Float) : MotionDurationScale
3384
3385 private class ScrollableContainerReaderNodeElement(val hasScrollableBlock: (Boolean) -> Unit) :
3386 ModifierNodeElement<ScrollableContainerReaderNode>() {
createnull3387 override fun create(): ScrollableContainerReaderNode {
3388 return ScrollableContainerReaderNode(hasScrollableBlock)
3389 }
3390
updatenull3391 override fun update(node: ScrollableContainerReaderNode) {
3392 node.hasScrollableBlock = hasScrollableBlock
3393 node.onUpdate()
3394 }
3395
hashCodenull3396 override fun hashCode(): Int = hasScrollableBlock.hashCode()
3397
3398 override fun equals(other: Any?): Boolean {
3399 if (this === other) return true
3400 if (other === null) return false
3401 if (this::class != other::class) return false
3402
3403 other as ScrollableContainerReaderNodeElement
3404
3405 if (hasScrollableBlock != other.hasScrollableBlock) return false
3406
3407 return true
3408 }
3409 }
3410
3411 private class ScrollableContainerReaderNode(var hasScrollableBlock: (Boolean) -> Unit) :
3412 Modifier.Node(), TraversableNode {
3413 override val traverseKey: Any = TraverseKey
3414
onAttachnull3415 override fun onAttach() {
3416 hasScrollableBlock.invoke(hasScrollableContainer())
3417 }
3418
onUpdatenull3419 fun onUpdate() {
3420 hasScrollableBlock.invoke(hasScrollableContainer())
3421 }
3422
3423 companion object TraverseKey
3424 }
3425