1 /*
<lambda>null2  * Copyright 2019 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 package androidx.compose.foundation
17 
18 import android.os.Handler
19 import android.os.Looper
20 import androidx.annotation.RequiresApi
21 import androidx.compose.foundation.gestures.Orientation
22 import androidx.compose.foundation.gestures.Orientation.Horizontal
23 import androidx.compose.foundation.gestures.Orientation.Vertical
24 import androidx.compose.foundation.gestures.animateScrollBy
25 import androidx.compose.foundation.gestures.scrollBy
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.Column
28 import androidx.compose.foundation.layout.IntrinsicSize
29 import androidx.compose.foundation.layout.Row
30 import androidx.compose.foundation.layout.Spacer
31 import androidx.compose.foundation.layout.fillMaxHeight
32 import androidx.compose.foundation.layout.fillMaxSize
33 import androidx.compose.foundation.layout.fillMaxWidth
34 import androidx.compose.foundation.layout.height
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.size
37 import androidx.compose.foundation.layout.width
38 import androidx.compose.foundation.text.BasicText
39 import androidx.compose.runtime.Composable
40 import androidx.compose.runtime.CompositionLocalProvider
41 import androidx.compose.runtime.DisposableEffect
42 import androidx.compose.runtime.SideEffect
43 import androidx.compose.runtime.getValue
44 import androidx.compose.runtime.mutableStateOf
45 import androidx.compose.runtime.rememberCoroutineScope
46 import androidx.compose.runtime.setValue
47 import androidx.compose.testutils.assertPixels
48 import androidx.compose.testutils.assertShape
49 import androidx.compose.testutils.first
50 import androidx.compose.testutils.toList
51 import androidx.compose.ui.Modifier
52 import androidx.compose.ui.MotionDurationScale
53 import androidx.compose.ui.draw.drawBehind
54 import androidx.compose.ui.geometry.Offset
55 import androidx.compose.ui.geometry.Size
56 import androidx.compose.ui.graphics.Color
57 import androidx.compose.ui.graphics.RectangleShape
58 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
59 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
60 import androidx.compose.ui.layout.IntrinsicMeasurable
61 import androidx.compose.ui.layout.IntrinsicMeasureScope
62 import androidx.compose.ui.layout.Layout
63 import androidx.compose.ui.layout.LayoutModifier
64 import androidx.compose.ui.layout.Measurable
65 import androidx.compose.ui.layout.MeasurePolicy
66 import androidx.compose.ui.layout.MeasureResult
67 import androidx.compose.ui.layout.MeasureScope
68 import androidx.compose.ui.layout.OnRemeasuredModifier
69 import androidx.compose.ui.layout.onSizeChanged
70 import androidx.compose.ui.node.DelegatableNode
71 import androidx.compose.ui.node.DrawModifierNode
72 import androidx.compose.ui.platform.InspectableValue
73 import androidx.compose.ui.platform.LocalDensity
74 import androidx.compose.ui.platform.LocalLayoutDirection
75 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
76 import androidx.compose.ui.platform.testTag
77 import androidx.compose.ui.semantics.SemanticsActions
78 import androidx.compose.ui.semantics.SemanticsProperties
79 import androidx.compose.ui.semantics.getOrNull
80 import androidx.compose.ui.test.SemanticsNodeInteraction
81 import androidx.compose.ui.test.TouchInjectionScope
82 import androidx.compose.ui.test.assertIsDisplayed
83 import androidx.compose.ui.test.assertIsNotDisplayed
84 import androidx.compose.ui.test.captureToImage
85 import androidx.compose.ui.test.junit4.StateRestorationTester
86 import androidx.compose.ui.test.junit4.createComposeRule
87 import androidx.compose.ui.test.onNodeWithTag
88 import androidx.compose.ui.test.onNodeWithText
89 import androidx.compose.ui.test.performScrollTo
90 import androidx.compose.ui.test.performSemanticsAction
91 import androidx.compose.ui.test.performTouchInput
92 import androidx.compose.ui.test.swipeDown
93 import androidx.compose.ui.test.swipeLeft
94 import androidx.compose.ui.test.swipeRight
95 import androidx.compose.ui.test.swipeUp
96 import androidx.compose.ui.unit.Constraints
97 import androidx.compose.ui.unit.Dp
98 import androidx.compose.ui.unit.IntSize
99 import androidx.compose.ui.unit.LayoutDirection
100 import androidx.compose.ui.unit.LayoutDirection.Ltr
101 import androidx.compose.ui.unit.LayoutDirection.Rtl
102 import androidx.compose.ui.unit.Velocity
103 import androidx.compose.ui.unit.dp
104 import androidx.test.filters.LargeTest
105 import androidx.test.filters.MediumTest
106 import androidx.test.filters.SdkSuppress
107 import androidx.testutils.AnimationDurationScaleRule
108 import com.google.common.truth.Truth.assertThat
109 import com.google.common.truth.Truth.assertWithMessage
110 import java.util.concurrent.CountDownLatch
111 import java.util.concurrent.TimeUnit
112 import kotlinx.coroutines.CoroutineScope
113 import kotlinx.coroutines.CoroutineStart
114 import kotlinx.coroutines.launch
115 import org.junit.After
116 import org.junit.Assert.assertEquals
117 import org.junit.Assert.assertFalse
118 import org.junit.Assert.assertTrue
119 import org.junit.Before
120 import org.junit.Rule
121 import org.junit.Test
122 import org.junit.runner.RunWith
123 import org.junit.runners.Parameterized
124 
125 @MediumTest
126 @RunWith(Parameterized::class)
127 class ScrollTest(private val config: Config) {
128 
129     data class Config(
130         val orientation: Orientation,
131         val layoutDirection: LayoutDirection,
132     )
133 
134     companion object {
135         @JvmStatic
136         @Parameterized.Parameters(name = "{0}")
137         fun data(): List<Config> =
138             listOf(
139                 // Don't need to check both directions for vertical scrolling.
140                 Config(Vertical, Ltr),
141                 Config(Horizontal, Ltr),
142                 Config(Horizontal, Rtl),
143             )
144     }
145 
146     @get:Rule val rule = createComposeRule()
147 
148     private val scrollerTag = "ScrollerTest"
149 
150     private val defaultCrossAxisSize = 45
151     private val defaultMainAxisSize = 40
152     private val defaultCellSize = 5
153     private val colors =
154         listOf(
155             Color(red = 0xFF, green = 0, blue = 0, alpha = 0xFF),
156             Color(red = 0xFF, green = 0xA5, blue = 0, alpha = 0xFF),
157             Color(red = 0xFF, green = 0xFF, blue = 0, alpha = 0xFF),
158             Color(red = 0xA5, green = 0xFF, blue = 0, alpha = 0xFF),
159             Color(red = 0, green = 0xFF, blue = 0, alpha = 0xFF),
160             Color(red = 0, green = 0xFF, blue = 0xA5, alpha = 0xFF),
161             Color(red = 0, green = 0, blue = 0xFF, alpha = 0xFF),
162             Color(red = 0xA5, green = 0, blue = 0xFF, alpha = 0xFF)
163         )
164 
165     @get:Rule
166     val animationScaleRule: AnimationDurationScaleRule =
167         AnimationDurationScaleRule.createForAllTests(1f)
168 
169     private lateinit var scope: CoroutineScope
170 
171     @Composable
172     private fun ExtractCoroutineScope() {
173         val actualScope = rememberCoroutineScope()
174         SideEffect { scope = actualScope }
175     }
176 
177     @Before
178     fun before() {
179         isDebugInspectorInfoEnabled = true
180     }
181 
182     @After
183     fun after() {
184         isDebugInspectorInfoEnabled = false
185     }
186 
187     @SdkSuppress(minSdkVersion = 26)
188     @Test
189     fun smallContent() {
190         val size = 40
191 
192         composeScroller(mainAxisSize = size)
193 
194         validateScroller(mainAxis = size)
195     }
196 
197     @Test
198     fun smallContent_Unscrollable() {
199         val scrollState = ScrollState(initial = 0)
200 
201         composeScroller(scrollState)
202 
203         rule.runOnIdle { assertTrue(scrollState.maxValue == 0) }
204     }
205 
206     @SdkSuppress(minSdkVersion = 26)
207     @Test
208     fun largeContent_NoScroll() {
209         val size = 30
210 
211         composeScroller(mainAxisSize = size)
212 
213         validateScroller(mainAxis = size)
214     }
215 
216     @SdkSuppress(minSdkVersion = 26)
217     @Test
218     fun largeContent_ScrollToEnd() {
219         val scrollState = ScrollState(initial = 0)
220         val size = 30
221         val scrollDistance = 10
222 
223         composeScroller(scrollState, mainAxisSize = size)
224 
225         validateScroller(mainAxis = size)
226 
227         rule.waitForIdle()
228         assertEquals(scrollDistance, scrollState.maxValue)
229         scope.launch { scrollState.scrollTo(scrollDistance) }
230 
231         validateScroller(offset = scrollDistance, mainAxis = size)
232     }
233 
234     @SdkSuppress(minSdkVersion = 26)
235     @Test
236     fun reversed() {
237         val scrollState = ScrollState(initial = 0)
238         val size = 30
239         val expectedOffset = defaultCellSize * colors.size - size
240 
241         composeScroller(scrollState, mainAxisSize = size, isReversed = true)
242 
243         validateScroller(offset = expectedOffset, mainAxis = size)
244     }
245 
246     @SdkSuppress(minSdkVersion = 26)
247     @Test
248     fun largeContent_Reversed_ScrollToEnd() {
249         val scrollState = ScrollState(initial = 0)
250         val size = 20
251         val scrollDistance = 10
252         val expectedOffset = defaultCellSize * colors.size - size - scrollDistance
253 
254         composeScroller(scrollState, mainAxisSize = size, isReversed = true)
255 
256         scope.launch { scrollState.scrollTo(scrollDistance) }
257 
258         validateScroller(offset = expectedOffset, mainAxis = size)
259     }
260 
261     @Test
262     fun scrollTo_scrollForward() {
263         createScrollableContent()
264 
265         rule.onNodeWithText("50").assertIsNotDisplayed().performScrollTo().assertIsDisplayed()
266     }
267 
268     @Test
269     fun reversed_scrollTo_scrollForward() {
270         createScrollableContent(isReversed = true)
271 
272         rule.onNodeWithText("50").assertIsNotDisplayed().performScrollTo().assertIsDisplayed()
273     }
274 
275     @Test
276     fun scrollTo_scrollBack() {
277         createScrollableContent()
278 
279         rule.onNodeWithText("50").assertIsNotDisplayed().performScrollTo().assertIsDisplayed()
280 
281         rule.onNodeWithText("20").assertIsNotDisplayed().performScrollTo().assertIsDisplayed()
282     }
283 
284     @Test
285     @LargeTest
286     fun swipeForward_swipeBackward() {
287         swipeScrollerAndBack(
288             isVertical = config.orientation == Vertical,
289             isRtl = config.layoutDirection == Rtl,
290             firstSwipe = { configAwareSwipe(forward = true) },
291             secondSwipe = { configAwareSwipe(forward = false) }
292         )
293     }
294 
295     @Test
296     fun scroller_coerce_whenScrollTo() {
297         val scrollState = ScrollState(initial = 0)
298 
299         fun scrollBy(delta: Float) {
300             scope.launch { scrollState.scrollBy(delta) }
301             rule.waitForIdle()
302         }
303 
304         fun scrollTo(position: Int) {
305             scope.launch { scrollState.scrollTo(position) }
306             rule.waitForIdle()
307         }
308 
309         createScrollableContent(
310             isVertical = config.orientation == Vertical,
311             scrollState = scrollState
312         )
313 
314         rule.waitForIdle()
315         assertThat(scrollState.value).isEqualTo(0)
316         assertThat(scrollState.maxValue).isGreaterThan(0)
317 
318         scrollBy(-100f)
319         assertThat(scrollState.value).isEqualTo(0)
320 
321         scrollBy(-100f)
322         assertThat(scrollState.value).isEqualTo(0)
323 
324         scrollTo(scrollState.maxValue)
325         assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
326 
327         scrollTo(scrollState.maxValue + 1000)
328         assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
329 
330         scrollBy(100f)
331         assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
332     }
333 
334     @Test
335     fun largeContent_coerceWhenMaxChanges() {
336         val scrollState = ScrollState(initial = 0)
337         val itemCount = mutableStateOf(100)
338 
339         createScrollableContent(scrollState = scrollState, itemCount = { itemCount.value })
340 
341         rule.waitForIdle()
342         assertThat(scrollState.value).isEqualTo(0)
343         assertThat(scrollState.maxValue).isGreaterThan(0)
344         val max = scrollState.maxValue
345 
346         scope.launch { scrollState.scrollTo(max) }
347         rule.waitForIdle()
348         itemCount.value -= 2
349 
350         rule.waitForIdle()
351         val newMax = scrollState.maxValue
352         assertThat(newMax).isLessThan(max)
353         assertThat(scrollState.value).isEqualTo(newMax)
354     }
355 
356     @Test
357     fun scroller_coerce_whenScrollSmoothTo() {
358         val scrollState = ScrollState(initial = 0)
359 
360         fun animateScrollTo(delta: Int) {
361             scope.launch { scrollState.animateScrollTo(delta) }
362             rule.waitForIdle()
363         }
364 
365         fun animateScrollBy(delta: Float) {
366             scope.launch { scrollState.animateScrollBy(delta) }
367             rule.waitForIdle()
368         }
369 
370         createScrollableContent(scrollState = scrollState)
371 
372         rule.waitForIdle()
373         assertThat(scrollState.value).isEqualTo(0)
374         assertThat(scrollState.maxValue).isGreaterThan(0)
375         val max = scrollState.maxValue
376 
377         animateScrollTo(-100)
378         assertThat(scrollState.value).isEqualTo(0)
379 
380         animateScrollBy(-100f)
381         assertThat(scrollState.value).isEqualTo(0)
382 
383         animateScrollTo(scrollState.maxValue)
384         assertThat(scrollState.value).isEqualTo(max)
385 
386         animateScrollTo(scrollState.maxValue + 1000)
387         assertThat(scrollState.value).isEqualTo(max)
388 
389         animateScrollBy(100f)
390         assertThat(scrollState.value).isEqualTo(max)
391     }
392 
393     @Test
394     fun scroller_whenFling_stopsByTouchDown() {
395         rule.mainClock.autoAdvance = false
396         val scrollState = ScrollState(initial = 0)
397 
398         createScrollableContent(scrollState = scrollState)
399 
400         assertThat(scrollState.value).isEqualTo(0)
401         assertThat(scrollState.isScrollInProgress).isEqualTo(false)
402 
403         rule.onNodeWithTag(scrollerTag).performTouchInput { configAwareSwipe() }
404 
405         assertThat(scrollState.isScrollInProgress).isEqualTo(true)
406         val scrollAtFlingStart = scrollState.value
407 
408         // Let the fling run for a bit
409         rule.mainClock.advanceTimeBy(100)
410 
411         // Interrupt the fling
412         val scrollWhenInterruptFling = scrollState.value
413         assertThat(scrollWhenInterruptFling).isGreaterThan(scrollAtFlingStart)
414         rule.onNodeWithTag(scrollerTag).performTouchInput { down(center) }
415 
416         // The fling has been stopped:
417         rule.mainClock.advanceTimeBy(100)
418         assertThat(scrollState.value).isEqualTo(scrollWhenInterruptFling)
419     }
420 
421     @Test
422     fun scroller_restoresScrollerPosition() {
423         val restorationTester = StateRestorationTester(rule)
424         var scrollState: ScrollState? = null
425 
426         restorationTester.setContent {
427             ExtractCoroutineScope()
428             val actualState = rememberScrollState()
429             SideEffect { scrollState = actualState }
430             val content = @Composable { repeat(50) { Box(Modifier.size(100.dp)) } }
431             when (config.orientation) {
432                 Vertical -> {
433                     Column(Modifier.verticalScroll(actualState)) { content() }
434                 }
435                 Horizontal -> {
436                     CompositionLocalProvider(LocalLayoutDirection provides config.layoutDirection) {
437                         Row(Modifier.horizontalScroll(actualState)) { content() }
438                     }
439                 }
440             }
441         }
442 
443         rule.waitForIdle()
444         scope.launch { scrollState!!.scrollTo(70) }
445         rule.waitForIdle()
446         scrollState = null
447 
448         restorationTester.emulateSavedInstanceStateRestore()
449 
450         rule.runOnIdle { assertThat(scrollState!!.value).isEqualTo(70) }
451     }
452 
453     @Test
454     fun scroller_semanticsScroll_isAnimated() {
455         rule.mainClock.autoAdvance = false
456         val scrollState = ScrollState(initial = 0)
457 
458         createScrollableContent(scrollState = scrollState)
459 
460         rule.waitForIdle()
461         assertThat(scrollState.value).isEqualTo(0)
462         assertThat(scrollState.maxValue).isGreaterThan(100) // If this fails, just add more items
463 
464         rule.onNodeWithTag(scrollerTag).performSemanticsAction(SemanticsActions.ScrollBy) {
465             when (config.orientation) {
466                 Vertical -> it(0f, 100f)
467                 Horizontal -> it(100f, 0f)
468             }
469         }
470 
471         // We haven't advanced time yet, make sure it's still zero
472         assertThat(scrollState.value).isEqualTo(0)
473 
474         // Advance and make sure we're partway through
475         // Note that we need two frames for the animation to actually happen
476         rule.mainClock.advanceTimeByFrame()
477         rule.mainClock.advanceTimeByFrame()
478         assertThat(scrollState.value).isGreaterThan(0)
479         assertThat(scrollState.value).isLessThan(100)
480 
481         // Finish the scroll, make sure we're at the target
482         rule.mainClock.advanceTimeBy(5000)
483         assertThat(scrollState.value).isEqualTo(100)
484     }
485 
486     @Test
487     fun scroller_semanticsScrollByOffset_isAnimated() {
488         rule.mainClock.autoAdvance = false
489         val scrollState = ScrollState(initial = 0)
490 
491         createScrollableContent(scrollState = scrollState)
492 
493         rule.waitForIdle()
494         assertThat(scrollState.value).isEqualTo(0)
495         assertThat(scrollState.maxValue).isGreaterThan(100) // If this fails, just add more items
496 
497         val action =
498             rule
499                 .onNodeWithTag(scrollerTag)
500                 .fetchSemanticsNode()
501                 .config[SemanticsActions.ScrollByOffset]
502         scope.launch(start = CoroutineStart.UNDISPATCHED) {
503             when (config.orientation) {
504                 Vertical -> action(Offset(0f, 100f))
505                 Horizontal -> action(Offset(100f, 0f))
506             }
507         }
508 
509         // We haven't advanced time yet, make sure it's still zero
510         assertThat(scrollState.value).isEqualTo(0)
511 
512         // Advance and make sure we're partway through
513         // Note that we need two frames for the animation to actually happen
514         rule.mainClock.advanceTimeByFrame()
515         rule.mainClock.advanceTimeByFrame()
516         assertThat(scrollState.value).isGreaterThan(0)
517         assertThat(scrollState.value).isLessThan(100)
518 
519         // Finish the scroll, make sure we're at the target
520         rule.mainClock.advanceTimeBy(5000)
521         assertThat(scrollState.value).isEqualTo(100)
522     }
523 
524     @Test
525     fun scroller_semanticsScrollByOffset_returnsConsumedScroll() {
526         val scrollState = ScrollState(initial = 0)
527         var consumedScroll = Offset.Unspecified
528 
529         createScrollableContent(scrollState = scrollState)
530 
531         rule.waitForIdle()
532         assertThat(scrollState.value).isEqualTo(0)
533         assertThat(scrollState.maxValue).isGreaterThan(100) // If this fails, just add more items
534 
535         val action =
536             rule
537                 .onNodeWithTag(scrollerTag)
538                 .fetchSemanticsNode()
539                 .config[SemanticsActions.ScrollByOffset]
540 
541         scope.launch {
542             consumedScroll =
543                 when (config.orientation) {
544                     Vertical -> action(Offset(0f, 100f))
545                     Horizontal -> action(Offset(100f, 0f))
546                 }
547         }
548         rule.runOnIdle {
549             assertThat(consumedScroll)
550                 .isEqualTo(
551                     when (config.orientation) {
552                         Vertical -> Offset(0f, 100f)
553                         Horizontal -> Offset(100f, 0f)
554                     }
555                 )
556         }
557 
558         // Try to scroll again, only consume part.
559         val expectedConsumed = scrollState.maxValue - scrollState.value
560         val impossibleScrollRequest = scrollState.maxValue + 10f
561         // b/330698760
562         scope.launch(DisableAnimationMotionDurationScale) {
563             consumedScroll =
564                 when (config.orientation) {
565                     Vertical -> action(Offset(0f, impossibleScrollRequest))
566                     Horizontal -> action(Offset(impossibleScrollRequest, 0f))
567                 }
568         }
569         rule.runOnIdle {
570             assertThat(consumedScroll)
571                 .isEqualTo(
572                     when (config.orientation) {
573                         Vertical -> Offset(0f, expectedConsumed.toFloat())
574                         Horizontal -> Offset(expectedConsumed.toFloat(), 0f)
575                     }
576                 )
577         }
578     }
579 
580     @Test
581     fun scroller_touchInputEnabled_shouldHaveSemanticsInfo() {
582         val scrollState = ScrollState(initial = 0)
583         val scrollNode = rule.onNodeWithTag(scrollerTag)
584         createScrollableContent(scrollState = scrollState)
585         val yScrollState =
586             scrollNode
587                 .fetchSemanticsNode()
588                 .config
589                 .getOrNull(
590                     when (config.orientation) {
591                         Vertical -> SemanticsProperties.VerticalScrollAxisRange
592                         Horizontal -> SemanticsProperties.HorizontalScrollAxisRange
593                     }
594                 )
595 
596         scrollNode.performTouchInput { configAwareSwipe() }
597 
598         assertThat(yScrollState?.value?.invoke()).isEqualTo(scrollState.value)
599     }
600 
601     @Test
602     fun scroller_touchInputDisabled_shouldHaveSemanticsInfo() {
603         val scrollState = ScrollState(initial = 0)
604         val scrollNode = rule.onNodeWithTag(scrollerTag)
605         createScrollableContent(scrollState = scrollState, touchInputEnabled = false)
606         val scrollSemantics =
607             scrollNode
608                 .fetchSemanticsNode()
609                 .config
610                 .getOrNull(
611                     when (config.orientation) {
612                         Vertical -> SemanticsProperties.VerticalScrollAxisRange
613                         Horizontal -> SemanticsProperties.HorizontalScrollAxisRange
614                     }
615                 )
616 
617         scrollNode.performTouchInput { configAwareSwipe() }
618 
619         assertThat(scrollSemantics?.value?.invoke()).isEqualTo(scrollState.value)
620     }
621 
622     @Test
623     fun overscrollWithOverscrollEnabled() {
624         animationScaleRule.setAnimationDurationScale(1f)
625 
626         val containerSize = with(rule.density) { 100.toDp() }
627         val contentSize = with(rule.density) { 110.toDp() }
628         val scrollState = ScrollState(initial = 0)
629         rule.setContent {
630             Box {
631                 Box(Modifier.size(containerSize, containerSize)) {
632                     when (config.orientation) {
633                         Vertical -> {
634                             Column(
635                                 Modifier.testTag(scrollerTag).verticalScroll(state = scrollState)
636                             ) {
637                                 Box(Modifier.height(contentSize).fillMaxWidth())
638                             }
639                         }
640                         Horizontal -> {
641                             CompositionLocalProvider(
642                                 LocalLayoutDirection provides config.layoutDirection
643                             ) {
644                                 Row(
645                                     Modifier.testTag(scrollerTag)
646                                         .horizontalScroll(state = scrollState)
647                                 ) {
648                                     Box(Modifier.width(contentSize).fillMaxHeight())
649                                 }
650                             }
651                         }
652                     }
653                 }
654             }
655         }
656 
657         rule.onNodeWithTag(scrollerTag).performTouchInput { configAwareSwipe() }
658 
659         rule.runOnIdle { assertThat(scrollState.value).isEqualTo(10) }
660     }
661 
662     @Test
663     fun testInspectorValue_withoutOverscrollParameter() {
664         val state = ScrollState(initial = 0)
665         rule.setContent {
666             val modifiers =
667                 when (config.orientation) {
668                     Vertical -> Modifier.verticalScroll(state)
669                     Horizontal -> Modifier.horizontalScroll(state)
670                 }.toList()
671 
672             val clip = modifiers[0] as InspectableValue
673             val scrollableContainer = modifiers[1] as InspectableValue
674             val scroll = modifiers[2] as InspectableValue
675 
676             assertThat(clip.nameFallback).isEqualTo("graphicsLayer")
677 
678             assertThat(scrollableContainer.nameFallback).isEqualTo("scrollingContainer")
679             assertThat(scrollableContainer.valueOverride).isNull()
680             assertThat(scrollableContainer.inspectableElements.map { it.name }.asIterable())
681                 .containsExactly(
682                     "state",
683                     "orientation",
684                     "enabled",
685                     "reverseScrolling",
686                     "flingBehavior",
687                     "interactionSource",
688                     "bringIntoViewSpec",
689                     "useLocalOverscrollFactory",
690                     "overscrollEffect"
691                 )
692 
693             assertThat(scroll.nameFallback).isEqualTo("scroll")
694             assertThat(scroll.valueOverride).isNull()
695             assertThat(scroll.inspectableElements.map { it.name }.asIterable())
696                 .containsExactly("state", "reverseScrolling", "isVertical")
697         }
698     }
699 
700     @Test
701     fun testInspectorValue_withOverscrollParameter() {
702         val state = ScrollState(initial = 0)
703         rule.setContent {
704             val modifiers =
705                 when (config.orientation) {
706                     Vertical -> Modifier.verticalScroll(state, overscrollEffect = null)
707                     Horizontal -> Modifier.horizontalScroll(state, overscrollEffect = null)
708                 }.toList()
709 
710             val clip = modifiers[0] as InspectableValue
711             val scrollableContainer = modifiers[1] as InspectableValue
712             val scroll = modifiers[2] as InspectableValue
713 
714             assertThat(clip.nameFallback).isEqualTo("graphicsLayer")
715 
716             assertThat(scrollableContainer.nameFallback).isEqualTo("scrollingContainer")
717             assertThat(scrollableContainer.valueOverride).isNull()
718             assertThat(scrollableContainer.inspectableElements.map { it.name }.asIterable())
719                 .containsExactly(
720                     "state",
721                     "orientation",
722                     "enabled",
723                     "reverseScrolling",
724                     "flingBehavior",
725                     "interactionSource",
726                     "bringIntoViewSpec",
727                     "useLocalOverscrollFactory",
728                     "overscrollEffect"
729                 )
730 
731             assertThat(scroll.nameFallback).isEqualTo("scroll")
732             assertThat(scroll.valueOverride).isNull()
733             assertThat(scroll.inspectableElements.map { it.name }.asIterable())
734                 .containsExactly("state", "reverseScrolling", "isVertical")
735         }
736     }
737 
738     @SdkSuppress(minSdkVersion = 26)
739     @Test
740     fun doesNotClipOverdraw() {
741         rule.setContent {
742             val scrollState = rememberScrollState(20)
743             Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
744                 val content =
745                     @Composable { repeat(4) { Box(Modifier.size(20.dp).drawOutsideOfBounds()) } }
746                 when (config.orientation) {
747                     Vertical -> {
748                         Column(Modifier.padding(20.dp).fillMaxSize().verticalScroll(scrollState)) {
749                             content()
750                         }
751                     }
752                     Horizontal -> {
753                         CompositionLocalProvider(
754                             LocalLayoutDirection provides config.layoutDirection
755                         ) {
756                             Row(
757                                 Modifier.padding(20.dp).fillMaxSize().horizontalScroll(scrollState)
758                             ) {
759                                 content()
760                             }
761                         }
762                     }
763                 }
764             }
765         }
766 
767         val (horizontalPadding, verticalPadding) =
768             when (config.orientation) {
769                 Vertical -> Pair(0.dp, 20.dp)
770                 Horizontal -> Pair(20.dp, 0.dp)
771             }
772 
773         rule
774             .onNodeWithTag("container")
775             .captureToImage()
776             .assertShape(
777                 density = rule.density,
778                 shape = RectangleShape,
779                 shapeColor = Color.Red,
780                 backgroundColor = Color.Gray,
781                 horizontalPadding = horizontalPadding,
782                 verticalPadding = verticalPadding
783             )
784     }
785 
786     @Test
787     fun intrinsicMeasurements() =
788         with(rule.density) {
789             rule.setContent {
790                 Layout(
791                     content = {
792                         CompositionLocalProvider(
793                             LocalLayoutDirection provides config.layoutDirection
794                         ) {
795                             Layout(
796                                 content = {},
797                                 modifier =
798                                     when (config.orientation) {
799                                         Vertical -> Modifier.verticalScroll(rememberScrollState())
800                                         Horizontal ->
801                                             Modifier.horizontalScroll(rememberScrollState())
802                                     },
803                                 object : MeasurePolicy {
804                                     override fun MeasureScope.measure(
805                                         measurables: List<Measurable>,
806                                         constraints: Constraints,
807                                     ) = layout(0, 0) {}
808 
809                                     override fun IntrinsicMeasureScope.minIntrinsicWidth(
810                                         measurables: List<IntrinsicMeasurable>,
811                                         height: Int,
812                                     ) = 10.dp.roundToPx()
813 
814                                     override fun IntrinsicMeasureScope.minIntrinsicHeight(
815                                         measurables: List<IntrinsicMeasurable>,
816                                         width: Int,
817                                     ) = 20.dp.roundToPx()
818 
819                                     override fun IntrinsicMeasureScope.maxIntrinsicWidth(
820                                         measurables: List<IntrinsicMeasurable>,
821                                         height: Int,
822                                     ) = 30.dp.roundToPx()
823 
824                                     override fun IntrinsicMeasureScope.maxIntrinsicHeight(
825                                         measurables: List<IntrinsicMeasurable>,
826                                         width: Int,
827                                     ) = 40.dp.roundToPx()
828                                 }
829                             )
830                         }
831                     }
832                 ) { measurables, _ ->
833                     val measurable = measurables.first()
834                     assertEquals(
835                         10.dp.roundToPx(),
836                         measurable.minIntrinsicWidth(Constraints.Infinity)
837                     )
838                     assertEquals(
839                         20.dp.roundToPx(),
840                         measurable.minIntrinsicHeight(Constraints.Infinity)
841                     )
842                     assertEquals(
843                         30.dp.roundToPx(),
844                         measurable.maxIntrinsicWidth(Constraints.Infinity)
845                     )
846                     assertEquals(
847                         40.dp.roundToPx(),
848                         measurable.maxIntrinsicHeight(Constraints.Infinity)
849                     )
850                     layout(0, 0) {}
851                 }
852             }
853             rule.waitForIdle()
854         }
855 
856     @Test
857     fun scrollStateMaxValue_changesOnResize_beforePlacement() {
858         val maxScrollValues = mutableListOf<Int>()
859 
860         rule.setContent {
861             val scrollState = rememberScrollState()
862 
863             DisposableEffect(scrollState) {
864                 maxScrollValues += scrollState.maxValue
865                 onDispose {}
866             }
867 
868             with(LocalDensity.current) {
869                 CompositionLocalProvider(LocalLayoutDirection provides config.layoutDirection) {
870                     Box(
871                         Modifier.size(100.toDp())
872                             // This callback is invoked after the measure pass but before the
873                             // placement pass. The initial max value should have been set by
874                             // this time.
875                             .onSizeChanged { maxScrollValues += scrollState.maxValue }
876                             .then(
877                                 when (config.orientation) {
878                                     Vertical -> Modifier.verticalScroll(scrollState)
879                                     Horizontal -> Modifier.horizontalScroll(scrollState)
880                                 }
881                             )
882                     ) {
883                         Spacer(Modifier.size(100.toDp()))
884                     }
885                 }
886             }
887         }
888 
889         rule.runOnIdle { assertThat(maxScrollValues).containsExactly(Int.MAX_VALUE, 0).inOrder() }
890     }
891 
892     @Test
893     fun minIntrinsic_mainAxis() {
894         var sizeParam by mutableStateOf(0)
895 
896         val layoutModifier =
897             object : LayoutModifier {
898                 override fun MeasureScope.measure(
899                     measurable: Measurable,
900                     constraints: Constraints
901                 ): MeasureResult {
902                     val p = measurable.measure(constraints)
903                     return layout(p.width, p.height) { p.place(0, 0) }
904                 }
905 
906                 override fun IntrinsicMeasureScope.minIntrinsicWidth(
907                     measurable: IntrinsicMeasurable,
908                     height: Int
909                 ): Int {
910                     sizeParam = height
911                     return measurable.minIntrinsicWidth(height)
912                 }
913 
914                 override fun IntrinsicMeasureScope.minIntrinsicHeight(
915                     measurable: IntrinsicMeasurable,
916                     width: Int
917                 ): Int {
918                     sizeParam = width
919                     return measurable.minIntrinsicHeight(width)
920                 }
921             }
922         rule.setContent {
923             Box(
924                 Modifier.intrinsicMainAxisSize(IntrinsicSize.Min)
925                     .scrollerWithOrientation()
926                     .then(layoutModifier)
927             )
928         }
929         rule.waitForIdle()
930         assertThat(sizeParam).isNotEqualTo(Constraints.Infinity)
931     }
932 
933     @Test
934     fun minIntrinsic_crossAxis() {
935         var sizeParam by mutableStateOf(0)
936 
937         val layoutModifier =
938             object : LayoutModifier {
939                 override fun MeasureScope.measure(
940                     measurable: Measurable,
941                     constraints: Constraints
942                 ): MeasureResult {
943                     val p = measurable.measure(constraints)
944                     return layout(p.width, p.height) { p.place(0, 0) }
945                 }
946 
947                 override fun IntrinsicMeasureScope.minIntrinsicWidth(
948                     measurable: IntrinsicMeasurable,
949                     height: Int
950                 ): Int {
951                     sizeParam = height
952                     return measurable.minIntrinsicWidth(height)
953                 }
954 
955                 override fun IntrinsicMeasureScope.minIntrinsicHeight(
956                     measurable: IntrinsicMeasurable,
957                     width: Int
958                 ): Int {
959                     sizeParam = width
960                     return measurable.minIntrinsicHeight(width)
961                 }
962             }
963         rule.setContent {
964             Box(
965                 Modifier.intrinsicCrossAxisSize(IntrinsicSize.Min)
966                     .scrollerWithOrientation()
967                     .then(layoutModifier)
968             )
969         }
970         rule.waitForIdle()
971         assertThat(sizeParam).isEqualTo(Constraints.Infinity)
972     }
973 
974     @Test
975     fun maxIntrinsic_mainAxis() {
976         var sizeParam by mutableStateOf(0)
977 
978         val layoutModifier =
979             object : LayoutModifier {
980                 override fun MeasureScope.measure(
981                     measurable: Measurable,
982                     constraints: Constraints
983                 ): MeasureResult {
984                     val p = measurable.measure(constraints)
985                     return layout(p.width, p.height) { p.place(0, 0) }
986                 }
987 
988                 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
989                     measurable: IntrinsicMeasurable,
990                     height: Int
991                 ): Int {
992                     sizeParam = height
993                     return measurable.minIntrinsicWidth(height)
994                 }
995 
996                 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
997                     measurable: IntrinsicMeasurable,
998                     width: Int
999                 ): Int {
1000                     sizeParam = width
1001                     return measurable.minIntrinsicHeight(width)
1002                 }
1003             }
1004         rule.setContent {
1005             Box(
1006                 Modifier.intrinsicMainAxisSize(IntrinsicSize.Max)
1007                     .scrollerWithOrientation()
1008                     .then(layoutModifier)
1009             )
1010         }
1011         rule.waitForIdle()
1012         assertThat(sizeParam).isNotEqualTo(Constraints.Infinity)
1013     }
1014 
1015     @Test
1016     fun maxIntrinsic_crossAxis() {
1017         var sizeParam by mutableStateOf(0)
1018 
1019         val layoutModifier =
1020             object : LayoutModifier {
1021                 override fun MeasureScope.measure(
1022                     measurable: Measurable,
1023                     constraints: Constraints
1024                 ): MeasureResult {
1025                     val p = measurable.measure(constraints)
1026                     return layout(p.width, p.height) { p.place(0, 0) }
1027                 }
1028 
1029                 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
1030                     measurable: IntrinsicMeasurable,
1031                     height: Int
1032                 ): Int {
1033                     sizeParam = height
1034                     return measurable.minIntrinsicWidth(height)
1035                 }
1036 
1037                 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
1038                     measurable: IntrinsicMeasurable,
1039                     width: Int
1040                 ): Int {
1041                     sizeParam = width
1042                     return measurable.minIntrinsicHeight(width)
1043                 }
1044             }
1045         rule.setContent {
1046             Box(
1047                 Modifier.intrinsicCrossAxisSize(IntrinsicSize.Max)
1048                     .scrollerWithOrientation()
1049                     .then(layoutModifier)
1050             )
1051         }
1052         rule.waitForIdle()
1053         assertThat(sizeParam).isEqualTo(Constraints.Infinity)
1054     }
1055 
1056     @Test
1057     fun canNotScrollForwardOrBackward() {
1058         val scrollState = ScrollState(initial = 0)
1059 
1060         composeScroller(scrollState)
1061 
1062         rule.runOnIdle {
1063             assertTrue(scrollState.maxValue == 0)
1064             assertFalse(scrollState.canScrollForward)
1065             assertFalse(scrollState.canScrollBackward)
1066         }
1067     }
1068 
1069     @SdkSuppress(minSdkVersion = 26)
1070     @Test
1071     fun canScrollForward() {
1072         val scrollState = ScrollState(initial = 0)
1073         val size = 30
1074 
1075         composeScroller(scrollState, mainAxisSize = size)
1076 
1077         validateScroller(mainAxis = size)
1078 
1079         rule.runOnIdle {
1080             assertTrue(scrollState.value == 0)
1081             assertTrue(scrollState.maxValue > 0)
1082             assertTrue(scrollState.canScrollForward)
1083             assertFalse(scrollState.canScrollBackward)
1084         }
1085     }
1086 
1087     @SdkSuppress(minSdkVersion = 26)
1088     @Test
1089     fun canScrollBackward() {
1090         val scrollState = ScrollState(initial = 0)
1091         val scrollDistance = 10
1092         val size = 30
1093 
1094         composeScroller(scrollState, mainAxisSize = size)
1095 
1096         validateScroller(mainAxis = size)
1097 
1098         rule.waitForIdle()
1099         assertEquals(scrollDistance, scrollState.maxValue)
1100         scope.launch { scrollState.scrollTo(scrollDistance) }
1101 
1102         rule.runOnIdle {
1103             assertTrue(scrollState.value == scrollDistance)
1104             assertTrue(scrollState.maxValue == scrollDistance)
1105             assertFalse(scrollState.canScrollForward)
1106             assertTrue(scrollState.canScrollBackward)
1107         }
1108     }
1109 
1110     @SdkSuppress(minSdkVersion = 26)
1111     @Test
1112     fun canScrollForwardAndBackward() {
1113         val scrollState = ScrollState(initial = 0)
1114         val scrollDistance = 5
1115         val size = 30
1116 
1117         composeScroller(scrollState, mainAxisSize = size)
1118 
1119         validateScroller(mainAxis = size)
1120 
1121         rule.waitForIdle()
1122         assertEquals(scrollDistance, scrollState.maxValue / 2)
1123         scope.launch { scrollState.scrollTo(scrollDistance) }
1124 
1125         rule.runOnIdle {
1126             assertTrue(scrollState.value == scrollDistance)
1127             assertTrue(scrollState.maxValue == scrollDistance * 2)
1128             assertTrue(scrollState.canScrollForward)
1129             assertTrue(scrollState.canScrollBackward)
1130         }
1131     }
1132 
1133     @Test
1134     fun viewPortSize_shouldRepresentScrollableLayoutSize_contentFits() {
1135         val state = ScrollState(0)
1136         val scrollerSize = colors.size * defaultCellSize
1137         composeScroller(scrollState = state, mainAxisSize = scrollerSize)
1138         assertThat(state.viewportSize).isEqualTo(scrollerSize)
1139     }
1140 
1141     @Test
1142     fun viewPortSize_shouldRepresentScrollableLayoutSize_contentDoesNotFit() {
1143         val state = ScrollState(0)
1144         val scrollerSize = 30
1145         composeScroller(scrollState = state, mainAxisSize = scrollerSize)
1146         assertThat(state.viewportSize).isEqualTo(scrollerSize)
1147     }
1148 
1149     @Test
1150     fun onMaxValueUpdate_shouldNotGenerateExtraMeasurements() {
1151         var measurements = 0
1152         lateinit var scrollState: ScrollState
1153 
1154         val sizeModifiers =
1155             if (config.orientation == Horizontal) {
1156                 Modifier.fillMaxWidth().height(100.dp)
1157             } else {
1158                 Modifier.width(100.dp).fillMaxHeight()
1159             }
1160 
1161         val wrapperModifiers =
1162             Modifier.testTag(scrollerTag)
1163                 .then(sizeModifiers)
1164                 .then(CountMeasureModifier { measurements++ })
1165 
1166         val content: @Composable () -> Unit = {
1167             repeat(25) { Box(modifier = Modifier.size(100.dp).padding(2.dp).background(Color.Red)) }
1168         }
1169 
1170         rule.setContent {
1171             scrollState = rememberScrollState()
1172 
1173             CompositionLocalProvider(LocalLayoutDirection provides config.layoutDirection) {
1174                 if (config.orientation == Horizontal) {
1175                     Row(
1176                         Modifier.horizontalScroll(scrollState).then(wrapperModifiers),
1177                         content = { content() }
1178                     )
1179                 } else {
1180                     Column(
1181                         Modifier.verticalScroll(scrollState).then(wrapperModifiers),
1182                         content = { content() }
1183                     )
1184                 }
1185             }
1186         }
1187 
1188         val previousMeasurement = measurements
1189 
1190         rule.onNodeWithTag(scrollerTag).performTouchInput { configAwareSwipe() }
1191 
1192         rule.runOnIdle {
1193             assertThat(scrollState.value).isNotEqualTo(0) // check we scrolled
1194             assertThat(measurements).isEqualTo(previousMeasurement) // no extra measurements
1195         }
1196     }
1197 
1198     @Test
1199     fun customOverscroll() {
1200         val containerSize = with(rule.density) { 100.toDp() }
1201         val contentSize = with(rule.density) { 110.toDp() }
1202         val scrollState = ScrollState(initial = 0)
1203         val overscroll = TestOverscrollEffect()
1204         rule.setContent {
1205             Box {
1206                 Box(Modifier.size(containerSize, containerSize)) {
1207                     when (config.orientation) {
1208                         Vertical -> {
1209                             Column(
1210                                 Modifier.testTag(scrollerTag)
1211                                     .verticalScroll(
1212                                         state = scrollState,
1213                                         overscrollEffect = overscroll
1214                                     )
1215                             ) {
1216                                 Box(Modifier.height(contentSize).fillMaxWidth())
1217                             }
1218                         }
1219                         Horizontal -> {
1220                             CompositionLocalProvider(
1221                                 LocalLayoutDirection provides config.layoutDirection
1222                             ) {
1223                                 Row(
1224                                     Modifier.testTag(scrollerTag)
1225                                         .horizontalScroll(
1226                                             state = scrollState,
1227                                             overscrollEffect = overscroll
1228                                         )
1229                                 ) {
1230                                     Box(Modifier.width(contentSize).fillMaxHeight())
1231                                 }
1232                             }
1233                         }
1234                     }
1235                 }
1236             }
1237         }
1238 
1239         // The overscroll modifier should be added / drawn
1240         rule.runOnIdle { assertThat(overscroll.drawCalled).isTrue() }
1241 
1242         // Swipe past the end
1243         rule.onNodeWithTag(scrollerTag).performTouchInput { configAwareSwipe() }
1244 
1245         rule.runOnIdle {
1246             assertThat(scrollState.value).isEqualTo(10)
1247             // The swipe will result in multiple scroll deltas
1248             assertThat(overscroll.applyToScrollCalledCount).isGreaterThan(1)
1249             assertThat(overscroll.applyToFlingCalledCount).isEqualTo(1)
1250             when (config.orientation) {
1251                 Vertical -> {
1252                     assertThat(overscroll.scrollOverscrollDelta.y).isLessThan(0)
1253                     assertThat(overscroll.flingOverscrollVelocity.y).isLessThan(0)
1254                 }
1255                 Horizontal -> {
1256                     when (config.layoutDirection) {
1257                         Ltr -> {
1258                             assertThat(overscroll.scrollOverscrollDelta.x).isLessThan(0)
1259                             assertThat(overscroll.flingOverscrollVelocity.x).isLessThan(0)
1260                         }
1261                         Rtl -> {
1262                             assertThat(overscroll.scrollOverscrollDelta.x).isGreaterThan(0)
1263                             assertThat(overscroll.flingOverscrollVelocity.x).isGreaterThan(0)
1264                         }
1265                     }
1266                 }
1267             }
1268         }
1269     }
1270 
1271     private fun Modifier.intrinsicMainAxisSize(size: IntrinsicSize): Modifier =
1272         if (config.orientation == Horizontal) {
1273             width(size)
1274         } else {
1275             height(size)
1276         }
1277 
1278     private fun Modifier.intrinsicCrossAxisSize(size: IntrinsicSize): Modifier =
1279         if (config.orientation == Vertical) {
1280             width(size)
1281         } else {
1282             height(size)
1283         }
1284 
1285     @Composable
1286     private fun Modifier.scrollerWithOrientation(): Modifier =
1287         if (config.orientation == Vertical) {
1288             verticalScroll(rememberScrollState())
1289         } else {
1290             horizontalScroll(rememberScrollState())
1291         }
1292 
1293     /**
1294      * Swipes forward (up/left) or backward given the current orientation and layout direction of
1295      * the test config.
1296      */
1297     private fun TouchInjectionScope.configAwareSwipe(forward: Boolean = true) =
1298         when (config.orientation) {
1299             Vertical -> if (forward) swipeUp() else swipeDown()
1300             Horizontal ->
1301                 when (config.layoutDirection) {
1302                     Ltr -> if (forward) swipeLeft() else swipeRight()
1303                     Rtl -> if (forward) swipeRight() else swipeLeft()
1304                 }
1305         }
1306 
1307     private fun composeScroller(
1308         scrollState: ScrollState? = null,
1309         isReversed: Boolean = false,
1310         mainAxisSize: Int = defaultMainAxisSize,
1311         crossAxisSize: Int = defaultCrossAxisSize,
1312         cellSize: Int = defaultCellSize
1313     ) {
1314         when (config.orientation) {
1315             Vertical ->
1316                 composeVerticalScroller(
1317                     scrollState = scrollState,
1318                     isReversed = isReversed,
1319                     width = crossAxisSize,
1320                     height = mainAxisSize,
1321                     rowHeight = cellSize
1322                 )
1323             Horizontal ->
1324                 composeHorizontalScroller(
1325                     scrollState = scrollState,
1326                     isReversed = isReversed,
1327                     width = mainAxisSize,
1328                     height = crossAxisSize,
1329                     isRtl = config.layoutDirection == Rtl
1330                 )
1331         }
1332     }
1333 
1334     private fun composeVerticalScroller(
1335         scrollState: ScrollState? = null,
1336         isReversed: Boolean = false,
1337         width: Int = defaultCrossAxisSize,
1338         height: Int = defaultMainAxisSize,
1339         rowHeight: Int = defaultCellSize
1340     ) {
1341         val resolvedState = scrollState ?: ScrollState(initial = 0)
1342         // We assume that the height of the device is more than 45 px
1343         with(rule.density) {
1344             rule.setContent {
1345                 ExtractCoroutineScope()
1346                 Box {
1347                     Column(
1348                         modifier =
1349                             Modifier.size(width.toDp(), height.toDp())
1350                                 .testTag(scrollerTag)
1351                                 .verticalScroll(resolvedState, reverseScrolling = isReversed)
1352                     ) {
1353                         colors.forEach { color ->
1354                             Box(Modifier.size(width.toDp(), rowHeight.toDp()).background(color))
1355                         }
1356                     }
1357                 }
1358             }
1359         }
1360     }
1361 
1362     private fun composeHorizontalScroller(
1363         scrollState: ScrollState? = null,
1364         isReversed: Boolean = false,
1365         width: Int = defaultMainAxisSize,
1366         height: Int = defaultCrossAxisSize,
1367         isRtl: Boolean = false
1368     ) {
1369         val resolvedState = scrollState ?: ScrollState(initial = 0)
1370         // We assume that the height of the device is more than 45 px
1371         with(rule.density) {
1372             rule.setContent {
1373                 ExtractCoroutineScope()
1374                 val direction = if (isRtl) Rtl else Ltr
1375                 CompositionLocalProvider(LocalLayoutDirection provides direction) {
1376                     Box {
1377                         Row(
1378                             modifier =
1379                                 Modifier.size(width.toDp(), height.toDp())
1380                                     .testTag(scrollerTag)
1381                                     .horizontalScroll(resolvedState, reverseScrolling = isReversed)
1382                         ) {
1383                             colors.forEach { color ->
1384                                 Box(
1385                                     Modifier.size(defaultCellSize.toDp(), height.toDp())
1386                                         .background(color)
1387                                 )
1388                             }
1389                         }
1390                     }
1391                 }
1392             }
1393         }
1394     }
1395 
1396     @RequiresApi(api = 26)
1397     private fun validateScroller(
1398         offset: Int = 0,
1399         mainAxis: Int = 40,
1400         crossAxis: Int = 45,
1401         cellSize: Int = 5
1402     ) {
1403         when (config.orientation) {
1404             Vertical ->
1405                 validateVerticalScroller(
1406                     offset = offset,
1407                     width = crossAxis,
1408                     height = mainAxis,
1409                     rowHeight = cellSize
1410                 )
1411             Horizontal ->
1412                 validateHorizontalScroller(
1413                     offset = offset,
1414                     width = mainAxis,
1415                     height = crossAxis,
1416                     checkInRtl = config.layoutDirection == Rtl
1417                 )
1418         }
1419     }
1420 
1421     @RequiresApi(api = 26)
1422     private fun validateVerticalScroller(
1423         offset: Int = 0,
1424         width: Int = 45,
1425         height: Int = 40,
1426         rowHeight: Int = 5
1427     ) {
1428         rule.onNodeWithTag(scrollerTag).captureToImage().assertPixels(
1429             expectedSize = IntSize(width, height)
1430         ) { pos ->
1431             val colorIndex = (offset + pos.y) / rowHeight
1432             colors[colorIndex]
1433         }
1434     }
1435 
1436     @RequiresApi(api = 26)
1437     private fun validateHorizontalScroller(
1438         offset: Int = 0,
1439         width: Int = 40,
1440         height: Int = 45,
1441         checkInRtl: Boolean = false
1442     ) {
1443         val scrollerWidth = colors.size * defaultCellSize
1444         val absoluteOffset = if (checkInRtl) scrollerWidth - width - offset else offset
1445         rule.onNodeWithTag(scrollerTag).captureToImage().assertPixels(
1446             expectedSize = IntSize(width, height)
1447         ) { pos ->
1448             val colorIndex = (absoluteOffset + pos.x) / defaultCellSize
1449             if (checkInRtl) colors[colors.size - 1 - colorIndex] else colors[colorIndex]
1450         }
1451     }
1452 
1453     private fun createScrollableContent(
1454         isVertical: Boolean = config.orientation == Vertical,
1455         itemCount: () -> Int = { 100 },
1456         width: Dp = 100.dp,
1457         height: Dp = 100.dp,
1458         isReversed: Boolean = false,
1459         scrollState: ScrollState? = null,
1460         isRtl: Boolean = config.layoutDirection == Rtl,
1461         touchInputEnabled: Boolean = true
1462     ) {
1463         val resolvedState = scrollState ?: ScrollState(initial = 0)
1464         rule.setContent {
1465             ExtractCoroutineScope()
1466             val content = @Composable { repeat(itemCount()) { BasicText(text = "$it") } }
1467             Box {
1468                 Box(Modifier.size(width, height).background(Color.White)) {
1469                     if (isVertical) {
1470                         Column(
1471                             Modifier.testTag(scrollerTag)
1472                                 .verticalScroll(
1473                                     resolvedState,
1474                                     enabled = touchInputEnabled,
1475                                     reverseScrolling = isReversed
1476                                 )
1477                         ) {
1478                             content()
1479                         }
1480                     } else {
1481                         val direction = if (isRtl) Rtl else Ltr
1482                         CompositionLocalProvider(LocalLayoutDirection provides direction) {
1483                             Row(
1484                                 Modifier.testTag(scrollerTag)
1485                                     .horizontalScroll(
1486                                         resolvedState,
1487                                         enabled = touchInputEnabled,
1488                                         reverseScrolling = isReversed
1489                                     )
1490                             ) {
1491                                 content()
1492                             }
1493                         }
1494                     }
1495                 }
1496             }
1497         }
1498     }
1499 
1500     // TODO(b/147291885): This should not be needed in the future.
1501     private fun SemanticsNodeInteraction.awaitScrollAnimation(
1502         scroller: ScrollState
1503     ): SemanticsNodeInteraction {
1504         val latch = CountDownLatch(1)
1505         val handler = Handler(Looper.getMainLooper())
1506         handler.post(
1507             object : Runnable {
1508                 override fun run() {
1509                     if (scroller.isScrollInProgress) {
1510                         handler.post(this)
1511                     } else {
1512                         latch.countDown()
1513                     }
1514                 }
1515             }
1516         )
1517         assertWithMessage("Scroll didn't finish after 20 seconds")
1518             .that(latch.await(20, TimeUnit.SECONDS))
1519             .isTrue()
1520         return this
1521     }
1522 
1523     private fun swipeScrollerAndBack(
1524         isVertical: Boolean = config.orientation == Vertical,
1525         firstSwipe: TouchInjectionScope.() -> Unit,
1526         secondSwipe: TouchInjectionScope.() -> Unit,
1527         isRtl: Boolean = config.layoutDirection == Rtl
1528     ) {
1529         rule.mainClock.autoAdvance = false
1530         val scrollState = ScrollState(initial = 0)
1531 
1532         createScrollableContent(isVertical, scrollState = scrollState, isRtl = isRtl)
1533 
1534         assertThat(scrollState.value).isEqualTo(0)
1535 
1536         rule.onNodeWithTag(scrollerTag).performTouchInput { firstSwipe() }
1537 
1538         rule.mainClock.advanceTimeBy(5000)
1539 
1540         rule.onNodeWithTag(scrollerTag).awaitScrollAnimation(scrollState)
1541 
1542         val scrolledValue = scrollState.value
1543         assertThat(scrolledValue).isGreaterThan(0)
1544 
1545         rule.onNodeWithTag(scrollerTag).performTouchInput { secondSwipe() }
1546 
1547         rule.mainClock.advanceTimeBy(5000)
1548 
1549         rule.onNodeWithTag(scrollerTag).awaitScrollAnimation(scrollState)
1550 
1551         assertThat(scrollState.value).isLessThan(scrolledValue)
1552     }
1553 
1554     private fun Modifier.drawOutsideOfBounds() = drawBehind {
1555         val inflate = 20.dp.roundToPx().toFloat()
1556         drawRect(
1557             Color.Red,
1558             Offset(-inflate, -inflate),
1559             Size(size.width + inflate * 2, size.height + inflate * 2)
1560         )
1561     }
1562 
1563     private class CountMeasureModifier(val onRemeasure: () -> Unit) : OnRemeasuredModifier {
1564         override fun onRemeasured(size: IntSize) {
1565             onRemeasure.invoke()
1566         }
1567     }
1568 
1569     private object DisableAnimationMotionDurationScale : MotionDurationScale {
1570         override val scaleFactor: Float
1571             get() = 0f
1572     }
1573 
1574     private class TestOverscrollEffect : OverscrollEffect {
1575         var applyToScrollCalledCount: Int = 0
1576             private set
1577 
1578         var applyToFlingCalledCount: Int = 0
1579             private set
1580 
1581         var scrollOverscrollDelta: Offset = Offset.Zero
1582             private set
1583 
1584         var flingOverscrollVelocity: Velocity = Velocity.Zero
1585             private set
1586 
1587         var drawCalled: Boolean = false
1588 
1589         override fun applyToScroll(
1590             delta: Offset,
1591             source: NestedScrollSource,
1592             performScroll: (Offset) -> Offset
1593         ): Offset {
1594             applyToScrollCalledCount++
1595             val consumed = performScroll(delta)
1596             scrollOverscrollDelta = delta - consumed
1597             return consumed
1598         }
1599 
1600         override suspend fun applyToFling(
1601             velocity: Velocity,
1602             performFling: suspend (Velocity) -> Velocity
1603         ) {
1604             applyToFlingCalledCount++
1605             val consumed = performFling(velocity)
1606             flingOverscrollVelocity = velocity - consumed
1607         }
1608 
1609         override val isInProgress: Boolean = false
1610         override val node: DelegatableNode =
1611             object : Modifier.Node(), DrawModifierNode {
1612                 override fun ContentDrawScope.draw() {
1613                     drawContent()
1614                     drawCalled = true
1615                 }
1616             }
1617     }
1618 }
1619