[go: nahoru, domu]

blob: c667bd3a8fb6fbfa6bea326fcbe260ba3e68dae5 [file] [log] [blame]
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation
import android.os.Handler
import android.os.Looper
import androidx.compose.animation.core.ExponentialDecay
import androidx.compose.animation.core.ManualAnimationClock
import androidx.annotation.RequiresApi
import androidx.compose.Composable
import androidx.compose.mutableStateOf
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import androidx.ui.core.Modifier
import androidx.ui.core.testTag
import androidx.compose.foundation.animation.FlingConfig
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Stack
import androidx.compose.foundation.layout.preferredHeight
import androidx.compose.foundation.layout.preferredSize
import androidx.ui.test.GestureScope
import androidx.ui.test.SemanticsNodeInteraction
import androidx.ui.test.StateRestorationTester
import androidx.ui.test.assertIsDisplayed
import androidx.ui.test.assertIsNotDisplayed
import androidx.ui.test.assertPixels
import androidx.ui.test.captureToBitmap
import androidx.ui.test.createComposeRule
import androidx.ui.test.performGesture
import androidx.ui.test.performScrollTo
import androidx.ui.test.onNodeWithTag
import androidx.ui.test.onNodeWithText
import androidx.ui.test.runOnIdle
import androidx.ui.test.runOnUiThread
import androidx.ui.test.click
import androidx.ui.test.swipeDown
import androidx.ui.test.swipeLeft
import androidx.ui.test.swipeRight
import androidx.ui.test.swipeUp
import androidx.ui.unit.Dp
import androidx.ui.unit.IntSize
import androidx.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@SmallTest
@RunWith(JUnit4::class)
class ScrollTest {
@get:Rule
val composeTestRule = createComposeRule()
private val scrollerTag = "ScrollerTest"
private val defaultCrossAxisSize = 45
private val defaultMainAxisSize = 40
private val defaultCellSize = 5
private val colors = listOf(
Color(red = 0xFF, green = 0, blue = 0, alpha = 0xFF),
Color(red = 0xFF, green = 0xA5, blue = 0, alpha = 0xFF),
Color(red = 0xFF, green = 0xFF, blue = 0, alpha = 0xFF),
Color(red = 0xA5, green = 0xFF, blue = 0, alpha = 0xFF),
Color(red = 0, green = 0xFF, blue = 0, alpha = 0xFF),
Color(red = 0, green = 0xFF, blue = 0xA5, alpha = 0xFF),
Color(red = 0, green = 0, blue = 0xFF, alpha = 0xFF),
Color(red = 0xA5, green = 0, blue = 0xFF, alpha = 0xFF)
)
@SdkSuppress(minSdkVersion = 26)
@Test
fun verticalScroller_SmallContent() {
val height = 40
composeVerticalScroller(height = height)
validateVerticalScroller(height = height)
}
@Test
fun verticalScroller_SmallContent_Unscrollable() {
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
composeVerticalScroller(scrollState)
runOnIdle {
assertTrue(scrollState.maxValue == 0f)
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun verticalScroller_LargeContent_NoScroll() {
val height = 30
composeVerticalScroller(height = height)
validateVerticalScroller(height = height)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun verticalScroller_LargeContent_ScrollToEnd() {
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
val height = 30
val scrollDistance = 10
composeVerticalScroller(scrollState, height = height)
validateVerticalScroller(height = height)
runOnIdle {
assertEquals(scrollDistance.toFloat(), scrollState.maxValue)
scrollState.scrollTo(scrollDistance.toFloat())
}
runOnIdle {} // Just so the block below is correct
validateVerticalScroller(offset = scrollDistance, height = height)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun verticalScroller_Reversed() {
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
val height = 30
val expectedOffset = defaultCellSize * colors.size - height
composeVerticalScroller(scrollState, height = height, isReversed = true)
validateVerticalScroller(offset = expectedOffset, height = height)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun verticalScroller_LargeContent_Reversed_ScrollToEnd() {
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
val height = 20
val scrollDistance = 10
val expectedOffset = defaultCellSize * colors.size - height - scrollDistance
composeVerticalScroller(scrollState, height = height, isReversed = true)
runOnIdle {
scrollState.scrollTo(scrollDistance.toFloat())
}
runOnIdle {} // Just so the block below is correct
validateVerticalScroller(offset = expectedOffset, height = height)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun horizontalScroller_SmallContent() {
val width = 40
composeHorizontalScroller(width = width)
validateHorizontalScroller(width = width)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun horizontalScroller_LargeContent_NoScroll() {
val width = 30
composeHorizontalScroller(width = width)
validateHorizontalScroller(width = width)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun horizontalScroller_LargeContent_ScrollToEnd() {
val width = 30
val scrollDistance = 10
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
composeHorizontalScroller(scrollState, width = width)
validateHorizontalScroller(width = width)
runOnIdle {
assertEquals(scrollDistance.toFloat(), scrollState.maxValue)
scrollState.scrollTo(scrollDistance.toFloat())
}
runOnIdle {} // Just so the block below is correct
validateHorizontalScroller(offset = scrollDistance, width = width)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun horizontalScroller_reversed() {
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
val width = 30
val expectedOffset = defaultCellSize * colors.size - width
composeHorizontalScroller(scrollState, width = width, isReversed = true)
validateHorizontalScroller(offset = expectedOffset, width = width)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun horizontalScroller_LargeContent_Reversed_ScrollToEnd() {
val width = 30
val scrollDistance = 10
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
val expectedOffset = defaultCellSize * colors.size - width - scrollDistance
composeHorizontalScroller(scrollState, width = width, isReversed = true)
runOnIdle {
scrollState.scrollTo(scrollDistance.toFloat())
}
runOnIdle {} // Just so the block below is correct
validateHorizontalScroller(offset = expectedOffset, width = width)
}
@Test
fun verticalScroller_scrollTo_scrollForward() {
createScrollableContent(isVertical = true)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun horizontalScroller_scrollTo_scrollForward() {
createScrollableContent(isVertical = false)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Ignore("Unignore when b/156389287 is fixed for proper reverve delegation")
@Test
fun verticalScroller_reversed_scrollTo_scrollForward() {
createScrollableContent(
isVertical = true,
scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
),
isReversed = true
)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Ignore("Unignore when b/156389287 is fixed for proper reverve delegation")
@Test
fun horizontalScroller_reversed_scrollTo_scrollForward() {
createScrollableContent(
isVertical = false,
scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
),
isReversed = true
)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Test
@Ignore("When b/157687898 is fixed, performScrollTo must be adjusted to use semantic bounds")
fun verticalScroller_scrollTo_scrollBack() {
createScrollableContent(isVertical = true)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
onNodeWithText("20")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Test
@Ignore("When b/157687898 is fixed, performScrollTo must be adjusted to use semantic bounds")
fun horizontalScroller_scrollTo_scrollBack() {
createScrollableContent(isVertical = false)
onNodeWithText("50")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
onNodeWithText("20")
.assertIsNotDisplayed()
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun verticalScroller_swipeUp_swipeDown() {
swipeScrollerAndBack(true, GestureScope::swipeUp, GestureScope::swipeDown)
}
@Test
fun horizontalScroller_swipeLeft_swipeRight() {
swipeScrollerAndBack(false, GestureScope::swipeLeft, GestureScope::swipeRight)
}
@Test
fun scroller_coerce_whenScrollTo() {
val clock = ManualAnimationClock(0)
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = clock
)
createScrollableContent(isVertical = true, scrollState = scrollState)
runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
assertThat(scrollState.maxValue).isGreaterThan(0f)
}
runOnUiThread {
scrollState.scrollTo(-100f)
}
runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
}
runOnUiThread {
scrollState.scrollBy(-100f)
}
runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
}
runOnUiThread {
scrollState.scrollTo(scrollState.maxValue)
}
runOnIdle {
assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
}
runOnUiThread {
scrollState.scrollTo(scrollState.maxValue + 1000)
}
runOnIdle {
assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
}
runOnUiThread {
scrollState.scrollBy(100f)
}
runOnIdle {
assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
}
}
@Test
fun verticalScroller_LargeContent_coerceWhenMaxChanges() {
val clock = ManualAnimationClock(0)
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = clock
)
val itemCount = mutableStateOf(100)
composeTestRule.setContent {
Stack {
ScrollableColumn(
scrollState = scrollState,
modifier = Modifier.preferredSize(100.dp).testTag(scrollerTag)
) {
for (i in 0..itemCount.value) {
Text(i.toString())
}
}
}
}
val max = runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
assertThat(scrollState.maxValue).isGreaterThan(0f)
scrollState.maxValue
}
runOnUiThread {
scrollState.scrollTo(max)
}
runOnUiThread {
itemCount.value -= 2
}
runOnIdle {
val newMax = scrollState.maxValue
assertThat(newMax).isLessThan(max)
assertThat(scrollState.value).isEqualTo(newMax)
}
}
@Test
fun scroller_coerce_whenScrollSmoothTo() {
val clock = ManualAnimationClock(0)
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = clock
)
createScrollableContent(isVertical = true, scrollState = scrollState)
val max = runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
assertThat(scrollState.maxValue).isGreaterThan(0f)
scrollState.maxValue
}
performWithAnimationWaitAndAssertPosition(0f, scrollState, clock) {
scrollState.smoothScrollTo(-100f)
}
performWithAnimationWaitAndAssertPosition(0f, scrollState, clock) {
scrollState.smoothScrollBy(-100f)
}
performWithAnimationWaitAndAssertPosition(max, scrollState, clock) {
scrollState.smoothScrollTo(scrollState.maxValue)
}
performWithAnimationWaitAndAssertPosition(max, scrollState, clock) {
scrollState.smoothScrollTo(scrollState.maxValue + 1000)
}
performWithAnimationWaitAndAssertPosition(max, scrollState, clock) {
scrollState.smoothScrollBy(100f)
}
}
@Test
fun scroller_whenFling_stopsByTouchDown() {
val clock = ManualAnimationClock(0)
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = clock
)
createScrollableContent(isVertical = true, scrollState = scrollState)
runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
assertThat(scrollState.isAnimationRunning).isEqualTo(false)
}
onNodeWithTag(scrollerTag)
.performGesture { swipeUp() }
runOnIdle {
clock.clockTimeMillis += 100
assertThat(scrollState.isAnimationRunning).isEqualTo(true)
}
// TODO (matvei/jelle): this should be down, and not click to be 100% fair
onNodeWithTag(scrollerTag)
.performGesture { click() }
runOnIdle {
assertThat(scrollState.isAnimationRunning).isEqualTo(false)
}
}
@Test
fun scroller_restoresScrollerPosition() {
val restorationTester = StateRestorationTester(composeTestRule)
var scrollState: ScrollState? = null
restorationTester.setContent {
scrollState = rememberScrollState()
ScrollableColumn(scrollState = scrollState!!) {
repeat(50) {
Box(Modifier.preferredHeight(100.dp))
}
}
}
runOnIdle {
scrollState!!.scrollTo(70f)
scrollState = null
}
restorationTester.emulateSavedInstanceStateRestore()
runOnIdle {
assertThat(scrollState!!.value).isEqualTo(70f)
}
}
private fun performWithAnimationWaitAndAssertPosition(
assertValue: Float,
scrollState: ScrollState,
clock: ManualAnimationClock,
uiAction: () -> Unit
) {
runOnUiThread {
uiAction.invoke()
}
runOnIdle {
clock.clockTimeMillis += 5000
}
onNodeWithTag(scrollerTag).awaitScrollAnimation(scrollState)
runOnIdle {
assertThat(scrollState.value).isEqualTo(assertValue)
}
}
private fun swipeScrollerAndBack(
isVertical: Boolean,
firstSwipe: GestureScope.() -> Unit,
secondSwipe: GestureScope.() -> Unit
) {
val clock = ManualAnimationClock(0)
val scrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = clock
)
createScrollableContent(isVertical, scrollState = scrollState)
runOnIdle {
assertThat(scrollState.value).isEqualTo(0f)
}
onNodeWithTag(scrollerTag)
.performGesture { firstSwipe() }
runOnIdle {
clock.clockTimeMillis += 5000
}
onNodeWithTag(scrollerTag)
.awaitScrollAnimation(scrollState)
val scrolledValue = runOnIdle {
scrollState.value
}
assertThat(scrolledValue).isGreaterThan(0f)
onNodeWithTag(scrollerTag)
.performGesture { secondSwipe() }
runOnIdle {
clock.clockTimeMillis += 5000
}
onNodeWithTag(scrollerTag)
.awaitScrollAnimation(scrollState)
runOnIdle {
assertThat(scrollState.value).isLessThan(scrolledValue)
}
}
private fun composeVerticalScroller(
scrollState: ScrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
),
isReversed: Boolean = false,
width: Int = defaultCrossAxisSize,
height: Int = defaultMainAxisSize,
rowHeight: Int = defaultCellSize
) {
// We assume that the height of the device is more than 45 px
with(composeTestRule.density) {
composeTestRule.setContent {
Stack {
ScrollableColumn(
scrollState = scrollState,
reverseScrollDirection = isReversed,
modifier = Modifier
.preferredSize(width.toDp(), height.toDp())
.testTag(scrollerTag)
) {
colors.forEach { color ->
Box(
Modifier.preferredSize(width.toDp(), rowHeight.toDp()),
backgroundColor = color
)
}
}
}
}
}
}
private fun composeHorizontalScroller(
scrollState: ScrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
),
isReversed: Boolean = false,
width: Int = defaultMainAxisSize,
height: Int = defaultCrossAxisSize,
columnWidth: Int = defaultCellSize
) {
// We assume that the height of the device is more than 45 px
with(composeTestRule.density) {
composeTestRule.setContent {
Stack {
ScrollableRow(
reverseScrollDirection = isReversed,
scrollState = scrollState,
modifier = Modifier
.preferredSize(width.toDp(), height.toDp())
.testTag(scrollerTag)
) {
colors.forEach { color ->
Box(
Modifier.preferredSize(columnWidth.toDp(), height.toDp()),
backgroundColor = color
)
}
}
}
}
}
}
@RequiresApi(api = 26)
private fun validateVerticalScroller(
offset: Int = 0,
width: Int = 45,
height: Int = 40,
rowHeight: Int = 5
) {
onNodeWithTag(scrollerTag)
.captureToBitmap()
.assertPixels(expectedSize = IntSize(width, height)) { pos ->
val colorIndex = (offset + pos.y) / rowHeight
colors[colorIndex]
}
}
@RequiresApi(api = 26)
private fun validateHorizontalScroller(
offset: Int = 0,
width: Int = 40,
height: Int = 45,
columnWidth: Int = 5
) {
onNodeWithTag(scrollerTag)
.captureToBitmap()
.assertPixels(expectedSize = IntSize(width, height)) { pos ->
val colorIndex = (offset + pos.x) / columnWidth
colors[colorIndex]
}
}
private fun createScrollableContent(
isVertical: Boolean,
itemCount: Int = 100,
width: Dp = 100.dp,
height: Dp = 100.dp,
isReversed: Boolean = false,
scrollState: ScrollState = ScrollState(
initial = 0f,
flingConfig = FlingConfig(ExponentialDecay()),
animationClock = ManualAnimationClock(0)
)
) {
composeTestRule.setContent {
val content = @Composable {
repeat(itemCount) {
Text(text = "$it")
}
}
Stack {
Box(
Modifier.preferredSize(width, height),
backgroundColor = Color.White
) {
if (isVertical) {
Box(Modifier.testTag(scrollerTag)) {
ScrollableColumn(
scrollState = scrollState,
reverseScrollDirection = isReversed
) {
content()
}
}
} else {
Box(Modifier.testTag(scrollerTag)) {
ScrollableRow(
scrollState = scrollState,
reverseScrollDirection = isReversed
) {
content()
}
}
}
}
}
}
}
// TODO(b/147291885): This should not be needed in the future.
private fun SemanticsNodeInteraction.awaitScrollAnimation(
scroller: ScrollState
): SemanticsNodeInteraction {
val latch = CountDownLatch(1)
val handler = Handler(Looper.getMainLooper())
handler.post(object : Runnable {
override fun run() {
if (scroller.isAnimationRunning) {
handler.post(this)
} else {
latch.countDown()
}
}
})
assertWithMessage("Scroll didn't finish after 20 seconds")
.that(latch.await(20, TimeUnit.SECONDS)).isTrue()
return this
}
}