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