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