blob: 3a33b3a4c4b1d22b2d018c48062bd99e07b7b745 [file] [log] [blame]
/*
* Copyright 2022 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.tv.foundation.lazy.list
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.testutils.WithTouchSlop
import androidx.compose.testutils.assertPixels
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.NativeKeyEvent
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher.Companion.keyIsDefined
import androidx.compose.ui.test.SemanticsMatcher.Companion.keyNotDefined
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.tv.foundation.PivotOffsets
import androidx.tv.foundation.lazy.AutoTestFrameClock
import androidx.tv.foundation.lazy.grid.keyPress
import com.google.common.collect.Range
import com.google.common.truth.IntegerSubject
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import java.util.concurrent.CountDownLatch
import kotlin.math.roundToInt
import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@LargeTest
@RunWith(Parameterized::class)
class LazyListTest(orientation: Orientation) : BaseLazyListTestWithOrientation(orientation) {
private val LazyListTag = "LazyListTag"
private val firstItemTag = "firstItemTag"
@Test
fun lazyListShowsCombinedItems() {
val itemTestTag = "itemTestTag"
val items = listOf(1, 2).map { it.toString() }
val indexedItems = listOf(3, 4, 5)
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
item {
Spacer(
Modifier.mainAxisSize(40.dp)
.then(fillParentMaxCrossAxis())
.testTag(itemTestTag)
)
}
items(items) {
Spacer(Modifier.mainAxisSize(40.dp).then(fillParentMaxCrossAxis()).testTag(it))
}
itemsIndexed(indexedItems) { index, item ->
Spacer(
Modifier.mainAxisSize(41.dp).then(fillParentMaxCrossAxis())
.testTag("$index-$item")
)
}
}
}
rule.onNodeWithTag(itemTestTag)
.assertIsDisplayed()
rule.onNodeWithTag("1")
.assertIsDisplayed()
rule.onNodeWithTag("2")
.assertIsDisplayed()
rule.onNodeWithTag("0-3")
.assertIsDisplayed()
rule.onNodeWithTag("1-4")
.assertIsDisplayed()
rule.onNodeWithTag("2-5")
.assertDoesNotExist()
}
@Test
fun lazyListAllowEmptyListItems() {
val itemTag = "itemTag"
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow {
items(emptyList<Any>()) { }
item {
Spacer(Modifier.size(10.dp).testTag(itemTag))
}
}
}
rule.onNodeWithTag(itemTag)
.assertIsDisplayed()
}
@Test
fun lazyListAllowsNullableItems() {
val items = listOf("1", null, "3")
val nullTestTag = "nullTestTag"
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
items(items) {
if (it != null) {
Spacer(
Modifier.mainAxisSize(101.dp)
.then(fillParentMaxCrossAxis())
.testTag(it)
)
} else {
Spacer(
Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
.testTag(nullTestTag)
)
}
}
}
}
rule.onNodeWithTag("1")
.assertIsDisplayed()
rule.onNodeWithTag(nullTestTag)
.assertIsDisplayed()
rule.onNodeWithTag("3")
.assertDoesNotExist()
}
@Test
fun lazyListOnlyVisibleItemsAdded() {
val items = (1..4).map { it.toString() }
rule.setContentWithTestViewConfiguration {
Box(Modifier.mainAxisSize(200.dp)) {
LazyColumnOrRow(pivotOffsets = PivotOffsets(parentFraction = 0.4f)) {
items(items) {
Spacer(
Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
)
}
}
}
}
rule.onNodeWithTag("1")
.assertIsDisplayed()
rule.onNodeWithTag("2")
.assertIsDisplayed()
rule.onNodeWithTag("3")
.assertDoesNotExist()
rule.onNodeWithTag("4")
.assertDoesNotExist()
}
@Test
fun lazyListScrollToShowItems123() {
val items = (1..4).map { it.toString() }
rule.setContentWithTestViewConfiguration {
Box(Modifier.mainAxisSize(200.dp)) {
LazyColumnOrRow(
modifier = Modifier.testTag(LazyListTag),
pivotOffsets = PivotOffsets(parentFraction = 0.3f)
) {
items(items) {
Box(
Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
.testTag(it).focusable().border(3.dp, Color.Red)
) {
BasicText(it)
}
}
}
}
}
rule.keyPress(3)
rule.onNodeWithTag("1")
.assertIsDisplayed()
rule.onNodeWithTag("2")
.assertIsDisplayed()
rule.onNodeWithTag("3")
.assertIsDisplayed()
rule.onNodeWithTag("4")
.assertIsNotDisplayed()
}
@Test
fun lazyListScrollToHideFirstItem() {
val items = (1..4).map { it.toString() }
rule.setContentWithTestViewConfiguration {
Box(Modifier.mainAxisSize(200.dp)) {
LazyColumnOrRow(modifier = Modifier.testTag(LazyListTag)) {
items(items) {
Box(
Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
.testTag(it).focusable()
)
}
}
}
}
rule.keyPress(3)
rule.onNodeWithTag("1")
.assertIsNotDisplayed()
rule.onNodeWithTag("2")
.assertIsDisplayed()
rule.onNodeWithTag("3")
.assertIsDisplayed()
}
@Test
fun lazyListScrollToShowItems234() {
val items = (1..4).map { it.toString() }
rule.setContentWithTestViewConfiguration {
Box(Modifier.mainAxisSize(200.dp)) {
LazyColumnOrRow(
modifier = Modifier.testTag(LazyListTag),
pivotOffsets = PivotOffsets(parentFraction = 0.3f)
) {
items(items) {
Box(
Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
.testTag(it).focusable()
)
}
}
}
}
rule.keyPress(4)
rule.onNodeWithTag("1")
.assertIsNotDisplayed()
rule.onNodeWithTag("2")
.assertIsDisplayed()
rule.onNodeWithTag("3")
.assertIsDisplayed()
rule.onNodeWithTag("4")
.assertIsDisplayed()
}
@Test
fun lazyListWrapsContent() = with(rule.density) {
val itemInsideLazyList = "itemInsideLazyList"
val itemOutsideLazyList = "itemOutsideLazyList"
var sameSizeItems by mutableStateOf(true)
rule.setContentWithTestViewConfiguration {
Column {
LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
items(listOf(1, 2)) {
if (it == 1) {
Spacer(Modifier.size(50.dp).testTag(itemInsideLazyList))
} else {
Spacer(Modifier.size(if (sameSizeItems) 50.dp else 70.dp))
}
}
}
Spacer(Modifier.size(50.dp).testTag(itemOutsideLazyList))
}
}
rule.onNodeWithTag(itemInsideLazyList)
.assertIsDisplayed()
rule.onNodeWithTag(itemOutsideLazyList)
.assertIsDisplayed()
var lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
var mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
var crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(50.dp.roundToPx())
rule.runOnIdle {
sameSizeItems = false
}
rule.waitForIdle()
rule.onNodeWithTag(itemInsideLazyList)
.assertIsDisplayed()
rule.onNodeWithTag(itemOutsideLazyList)
.assertIsDisplayed()
lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(120.dp.roundToPx())
assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(70.dp.roundToPx())
}
@Test
fun compositionsAreDisposed_whenNodesAreScrolledOff() {
var composed: Boolean
var disposed = false
// Ten 31dp spacers in a 300dp list
val latch = CountDownLatch(10)
rule.setContentWithTestViewConfiguration {
// Fixed size to eliminate device size as a factor
Box(Modifier.testTag(LazyListTag).mainAxisSize(300.dp)) {
LazyColumnOrRow(Modifier.fillMaxSize()) {
items(50) {
DisposableEffect(NeverEqualObject) {
composed = true
// Signal when everything is done composing
latch.countDown()
onDispose {
disposed = true
}
}
// There will be 10 of these in the 300dp box
Box(Modifier.mainAxisSize(31.dp).focusable()) {
BasicText(it.toString())
}
}
}
}
}
latch.await()
composed = false
assertWithMessage("Compositions were disposed before we did any scrolling")
.that(disposed).isFalse()
// Mostly a validity check, this is not part of the behavior under test
assertWithMessage("Additional composition occurred for no apparent reason")
.that(composed).isFalse()
Thread.sleep(5000L)
rule.keyPress(
if (vertical) NativeKeyEvent.KEYCODE_DPAD_DOWN else NativeKeyEvent.KEYCODE_DPAD_RIGHT,
13
)
Thread.sleep(5000L)
rule.waitForIdle()
assertWithMessage("No additional items were composed after scroll, scroll didn't work")
.that(composed).isTrue()
// We may need to modify this test once we prefetch/cache items outside the viewport
assertWithMessage(
"No compositions were disposed after scrolling, compositions were leaked"
).that(disposed).isTrue()
}
@Test
fun whenItemsAreInitiallyCreatedWith0SizeWeCanScrollWhenTheyExpanded() {
val thirdTag = "third"
val items = (1..3).toList()
var thirdHasSize by mutableStateOf(false)
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.fillMaxCrossAxis()
.mainAxisSize(100.dp)
.testTag(LazyListTag)
) {
items(items) {
if (it == 3) {
Box(
Modifier.testTag(thirdTag)
.then(fillParentMaxCrossAxis())
.mainAxisSize(if (thirdHasSize) 60.dp else 0.dp).focusable()
)
} else {
Box(Modifier.then(fillParentMaxCrossAxis()).mainAxisSize(60.dp).focusable())
}
}
}
}
rule.keyPress(3)
rule.onNodeWithTag(thirdTag)
.assertExists()
.assertIsNotDisplayed()
rule.runOnIdle {
thirdHasSize = true
}
rule.waitForIdle()
rule.keyPress(2)
rule.onNodeWithTag(thirdTag)
.assertIsDisplayed()
}
@Test
fun itemFillingParentWidth() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(
Modifier.fillParentMaxWidth().requiredHeight(50.dp).testTag(firstItemTag)
)
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(100.dp)
.assertHeightIsEqualTo(50.dp)
}
@Test
fun itemFillingParentHeight() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(
Modifier.requiredWidth(50.dp).fillParentMaxHeight().testTag(firstItemTag)
)
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(50.dp)
.assertHeightIsEqualTo(150.dp)
}
@Test
fun itemFillingParentSize() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(100.dp)
.assertHeightIsEqualTo(150.dp)
}
@Test
fun itemFillingParentWidthFraction() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(
Modifier.fillParentMaxWidth(0.7f)
.requiredHeight(50.dp)
.testTag(firstItemTag)
)
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(70.dp)
.assertHeightIsEqualTo(50.dp)
}
@Test
fun itemFillingParentHeightFraction() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(
Modifier.requiredWidth(50.dp)
.fillParentMaxHeight(0.3f)
.testTag(firstItemTag)
)
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(50.dp)
.assertHeightIsEqualTo(45.dp)
}
@Test
fun itemFillingParentSizeFraction() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
items(listOf(0)) {
Spacer(Modifier.fillParentMaxSize(0.5f).testTag(firstItemTag))
}
}
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(50.dp)
.assertHeightIsEqualTo(75.dp)
}
@Test
fun itemFillingParentSizeParentResized() {
var parentSize by mutableStateOf(100.dp)
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(parentSize)) {
items(listOf(0)) {
Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
}
}
}
rule.runOnIdle {
parentSize = 150.dp
}
rule.onNodeWithTag(firstItemTag)
.assertWidthIsEqualTo(150.dp)
.assertHeightIsEqualTo(150.dp)
}
@Ignore("Disabled due to b/277124390")
@Test
fun whenNotAnymoreAvailableItemWasDisplayed() {
var items by mutableStateOf((1..30).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(items) {
Box(Modifier.requiredSize(20.dp).testTag("$it").focusable())
}
}
}
// after scroll we will display items 16-20
rule.keyPress(17)
rule.runOnIdle {
items = (1..10).toList()
}
// there is no item 16 anymore so we will just display the last items 6-10
rule.onNodeWithTag("6")
.assertStartPositionIsAlmost(0.dp)
}
@Test
fun whenFewDisplayedItemsWereRemoved() {
var items by mutableStateOf((1..10).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it").focusable())
}
}
}
// after scroll we will display items 6-10
rule.keyPress(5)
rule.runOnIdle {
items = (1..8).toList()
}
// there are no more items 9 and 10, so we have to scroll back
rule.onNodeWithTag("4")
.assertStartPositionIsAlmost(0.dp)
}
@Test
fun whenItemsBecameEmpty() {
var items by mutableStateOf((1..10).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
modifier = Modifier.requiredSizeIn(maxHeight = 100.dp, maxWidth = 100.dp)
.testTag(LazyListTag)
) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it").focusable())
}
}
}
// after scroll we will display items 2-6
rule.keyPress(2)
rule.runOnIdle {
items = emptyList()
}
// there are no more items so the lazy list is zero sized
rule.onNodeWithTag(LazyListTag)
.assertWidthIsEqualTo(0.dp)
.assertHeightIsEqualTo(0.dp)
// and has no children
rule.onNodeWithTag("1")
.assertIsNotPlaced()
rule.onNodeWithTag("2")
.assertIsNotPlaced()
}
@Test
fun scrollBackAndForth() {
val items by mutableStateOf((1..20).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)
) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
// after scroll we will display items 6-10
rule.keyPress(5)
// and scroll back
rule.keyPress(5, reverseScroll = true)
rule.onNodeWithTag("1")
.assertStartPositionIsAlmost(0.dp)
}
@Test
fun tryToScrollBackwardWhenAlreadyOnTop() {
val items by mutableStateOf((1..20).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(items) {
Box(Modifier.requiredSize(20.dp).testTag("$it").focusable())
}
}
}
// getting focus to the first element
rule.keyPress(2)
// we already displaying the first item, so this should do nothing
rule.keyPress(4, reverseScroll = true)
rule.onNodeWithTag("1")
.assertStartPositionIsAlmost(0.dp)
rule.onNodeWithTag("2")
.assertStartPositionIsAlmost(20.dp)
rule.onNodeWithTag("3")
.assertStartPositionIsAlmost(40.dp)
rule.onNodeWithTag("4")
.assertStartPositionIsAlmost(60.dp)
rule.onNodeWithTag("5")
.assertStartPositionIsAlmost(80.dp)
}
@Test
fun contentOfNotStableItemsIsNotRecomposedDuringScroll() {
val items = listOf(NotStable(1), NotStable(2))
var firstItemRecomposed = 0
var secondItemRecomposed = 0
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(items) {
if (it.count == 1) {
firstItemRecomposed++
} else {
secondItemRecomposed++
}
Box(Modifier.requiredSize(75.dp).focusable())
}
}
}
rule.runOnIdle {
assertThat(firstItemRecomposed).isEqualTo(1)
assertThat(secondItemRecomposed).isEqualTo(1)
}
rule.keyPress(2)
rule.runOnIdle {
assertThat(firstItemRecomposed).isEqualTo(1)
assertThat(secondItemRecomposed).isEqualTo(1)
}
}
@Test
fun onlyOneMeasurePassForScrollEvent() {
val items by mutableStateOf((1..20).toList())
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
state = rememberTvLazyListState()
state.prefetchingEnabled = false
LazyColumnOrRow(
Modifier.requiredSize(100.dp).testTag(LazyListTag),
state = state
) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
val initialMeasurePasses = state.numMeasurePasses
rule.runOnIdle {
with(rule.density) {
state.onScroll(-110.dp.toPx())
}
}
rule.waitForIdle()
assertThat(state.numMeasurePasses).isEqualTo(initialMeasurePasses + 1)
}
@Test
fun onlyOneInitialMeasurePass() {
val items by mutableStateOf((1..20).toList())
lateinit var state: TvLazyListState
rule.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(
Modifier.requiredSize(100.dp).testTag(LazyListTag),
state = state
) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
rule.runOnIdle {
assertThat(state.numMeasurePasses).isEqualTo(1)
}
}
@Ignore // b/268211857
@Test
fun scroll_makeListSmaller_scroll() {
var items by mutableStateOf((1..100).toList())
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(items) {
Box(Modifier.requiredSize(10.dp).testTag("$it").focusable())
}
}
}
rule.keyPress(30)
rule.runOnIdle {
items = (1..11).toList()
}
rule.waitForIdle()
// try to scroll after the data set has been updated. this was causing a crash previously
rule.keyPress(1, reverseScroll = true)
rule.onNodeWithTag("11")
.assertIsDisplayed()
}
@Test
fun initialScrollIsApplied() {
val items by mutableStateOf((0..20).toList())
lateinit var state: TvLazyListState
val expectedOffset = with(rule.density) { 10.dp.roundToPx() }
rule.setContentWithTestViewConfiguration {
state = rememberTvLazyListState(2, expectedOffset)
LazyColumnOrRow(
Modifier.requiredSize(100.dp).testTag(LazyListTag),
state = state
) {
items(items) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(2)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
}
rule.onNodeWithTag("2")
.assertStartPositionInRootIsEqualTo((-10).dp)
}
@Test
fun stateIsRestored() {
val restorationTester = StateRestorationTester(rule)
var state: TvLazyListState? = null
restorationTester.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(
Modifier.requiredSize(100.dp).testTag(LazyListTag),
state = state!!
) {
items(20) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
rule.keyPress(3)
val (index, scrollOffset) = rule.runOnIdle {
state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
}
state = null
restorationTester.emulateSavedInstanceStateRestore()
rule.runOnIdle {
assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
}
}
@Test
fun snapToItemIndex() {
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
state = rememberTvLazyListState()
LazyColumnOrRow(
Modifier.requiredSize(100.dp).testTag(LazyListTag),
state = state
) {
items(20) {
Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
}
}
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(3, 10)
}
assertThat(state.firstVisibleItemIndex).isEqualTo(3)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
}
}
// TODO: Needs to be debugged and fixed for TV surfaces.
/*@Test
fun itemsAreNotRedrawnDuringScroll() {
val items = (0..20).toList()
val redrawCount = Array(6) { 0 }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag),
pivotOffsetConfig = PivotOffsets(parentFraction = 0f)
) {
items(items) {
Box(
Modifier.requiredSize(20.dp)
.testTag(it.toString())
.drawBehind {
redrawCount[it]++
if (redrawCount[it] != 1) {
Log.i("REMOVE_ME", Exception("Redrawn").stackTraceToString())
}
}
.focusable()
) {
BasicText(it.toString())
}
}
}
}
rule.keyPress(3)
rule.onNodeWithTag("0").assertIsNotDisplayed()
rule.runOnIdle {
redrawCount.forEachIndexed { index, i ->
assertWithMessage("Item with index $index was redrawn $i times")
.that(i).isEqualTo(1)
}
}
}*/
@Test
fun itemInvalidationIsNotCausingAnotherItemToRedraw() {
val redrawCount = Array(2) { 0 }
var stateUsedInDrawScope by mutableStateOf(false)
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
items(2) {
Spacer(
Modifier.requiredSize(50.dp)
.drawBehind {
redrawCount[it]++
if (it == 1) {
stateUsedInDrawScope.hashCode()
}
}
)
}
}
}
rule.runOnIdle {
stateUsedInDrawScope = true
}
rule.runOnIdle {
assertWithMessage("First items is not expected to be redrawn")
.that(redrawCount[0]).isEqualTo(1)
assertWithMessage("Second items is expected to be redrawn")
.that(redrawCount[1]).isEqualTo(2)
}
}
@Test
fun notVisibleAnymoreItemNotAffectingCrossAxisSize() {
val itemSize = with(rule.density) { 30.toDp() }
val itemSizeMinusOne = with(rule.density) { 29.toDp() }
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSizeMinusOne).testTag(LazyListTag),
state = rememberTvLazyListState().also { state = it }
) {
items(2) {
Spacer(
if (it == 0) {
Modifier.crossAxisSize(30.dp).mainAxisSize(itemSizeMinusOne)
} else {
Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
}
)
}
}
}
state.scrollBy(itemSize)
rule.onNodeWithTag(LazyListTag)
.assertCrossAxisSizeIsEqualTo(20.dp)
}
@Test
fun itemStillVisibleAfterOverscrollIsAffectingCrossAxisSize() {
val items = (0..2).toList()
val itemSize = with(rule.density) { 30.toDp() }
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 1.75f).testTag(LazyListTag),
state = rememberTvLazyListState().also { state = it }
) {
items(items) {
Spacer(
if (it == 0) {
Modifier.crossAxisSize(30.dp).mainAxisSize(itemSize / 2)
} else if (it == 1) {
Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize / 2)
} else {
Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
}
)
}
}
}
state.scrollBy(itemSize)
rule.onNodeWithTag(LazyListTag)
.assertCrossAxisSizeIsEqualTo(30.dp)
}
@Test
fun usedWithArray() {
val items = arrayOf("1", "2", "3")
val itemSize = with(rule.density) { 15.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow {
items(items) {
Spacer(Modifier.requiredSize(itemSize).testTag(it))
}
}
}
rule.onNodeWithTag("1")
.assertStartPositionInRootIsEqualTo(0.dp)
rule.onNodeWithTag("2")
.assertStartPositionInRootIsEqualTo(itemSize)
rule.onNodeWithTag("3")
.assertStartPositionInRootIsEqualTo(itemSize * 2)
}
@Test
fun usedWithArrayIndexed() {
val items = arrayOf("1", "2", "3")
val itemSize = with(rule.density) { 15.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow {
itemsIndexed(items) { index, item ->
Spacer(Modifier.requiredSize(itemSize).testTag("$index*$item"))
}
}
}
rule.onNodeWithTag("0*1")
.assertStartPositionInRootIsEqualTo(0.dp)
rule.onNodeWithTag("1*2")
.assertStartPositionInRootIsEqualTo(itemSize)
rule.onNodeWithTag("2*3")
.assertStartPositionInRootIsEqualTo(itemSize * 2)
}
@Test
fun changeItemsCountAndScrollImmediately() {
lateinit var state: TvLazyListState
var count by mutableStateOf(100)
val composedIndexes = mutableListOf<Int>()
rule.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(Modifier.fillMaxCrossAxis().mainAxisSize(10.dp), state) {
items(count) { index ->
composedIndexes.add(index)
Box(Modifier.size(20.dp))
}
}
}
rule.runOnIdle {
composedIndexes.clear()
count = 10
runBlocking(AutoTestFrameClock()) {
state.scrollToItem(50)
}
composedIndexes.forEach {
assertThat(it).isLessThan(count)
}
assertThat(state.firstVisibleItemIndex).isEqualTo(9)
}
}
@Test
fun overscrollingBackwardFromNotTheFirstPosition() {
val containerTag = "container"
val itemSizePx = 10
val itemSizeDp = with(rule.density) { itemSizePx.toDp() }
val containerSize = itemSizeDp * 5
rule.setContentWithTestViewConfiguration {
Box(
Modifier
.testTag(containerTag)
.size(containerSize)
) {
LazyColumnOrRow(
Modifier
.testTag(LazyListTag)
.background(Color.Blue),
state = rememberTvLazyListState(2, 5)
) {
items(100) {
Box(
Modifier
.fillMaxCrossAxis()
.mainAxisSize(itemSizeDp)
.testTag("$it")
.focusable()
)
}
}
}
}
rule.keyPress(
if (vertical) NativeKeyEvent.KEYCODE_DPAD_UP else NativeKeyEvent.KEYCODE_DPAD_LEFT,
15
)
rule.onNodeWithTag(LazyListTag)
.assertMainAxisSizeIsEqualTo(containerSize)
rule.onNodeWithTag("0")
.assertStartPositionInRootIsEqualTo(0.dp)
rule.onNodeWithTag("4")
.assertStartPositionInRootIsEqualTo(containerSize - itemSizeDp)
}
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun doesNotClipHorizontalOverdraw() {
rule.setContent {
Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
LazyColumnOrRow(
Modifier
.padding(20.dp)
.fillMaxSize(),
rememberTvLazyListState(1)
) {
items(4) {
Box(Modifier.size(20.dp).drawOutsideOfBounds())
}
}
}
}
val horizontalPadding = if (vertical) 0.dp else 20.dp
val verticalPadding = if (vertical) 20.dp else 0.dp
rule.onNodeWithTag("container")
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.Red,
backgroundColor = Color.Gray,
horizontalPadding = horizontalPadding,
verticalPadding = verticalPadding
)
}
@Test
fun initialScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
lateinit var state: TvLazyListState
var itemsCount by mutableStateOf(0)
rule.setContent {
state = rememberTvLazyListState(2, 10)
LazyColumnOrRow(Modifier.fillMaxSize(), state) {
items(itemsCount) {
Box(Modifier.size(20.dp))
}
}
}
rule.runOnIdle {
itemsCount = 100
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(2)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
}
}
@Test
fun restoredScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
lateinit var state: TvLazyListState
var itemsCount = 100
val recomposeCounter = mutableStateOf(0)
val tester = StateRestorationTester(rule)
tester.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(Modifier.fillMaxSize(), state) {
recomposeCounter.value
items(itemsCount) {
Box(Modifier.size(20.dp))
}
}
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(2, 10)
}
itemsCount = 0
}
tester.emulateSavedInstanceStateRestore()
rule.runOnIdle {
itemsCount = 100
recomposeCounter.value = 1
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(2)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
}
}
@Test
fun animateScrollToItemDoesNotScrollPastItem() {
lateinit var state: TvLazyListState
var target = 0
var reverse = false
rule.setContent {
val listState = rememberTvLazyListState()
SideEffect {
state = listState
}
LazyColumnOrRow(Modifier.fillMaxSize(), listState) {
items(2500) { _ ->
Box(Modifier.size(100.dp))
}
}
if (reverse) {
assertThat(listState.firstVisibleItemIndex).isAtLeast(target)
} else {
assertThat(listState.firstVisibleItemIndex).isAtMost(target)
}
}
// Try a bunch of different targets with varying spacing
listOf(500, 800, 1500, 1600, 1800).forEach {
target = it
rule.runOnIdle {
runBlocking(AutoTestFrameClock()) {
state.animateScrollToItem(target)
}
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(target)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
}
}
reverse = true
listOf(1600, 1500, 800, 500, 0).forEach {
target = it
rule.runOnIdle {
runBlocking(AutoTestFrameClock()) {
state.animateScrollToItem(target)
}
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(target)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
}
}
}
@Test
fun animateScrollToTheLastItemWhenItemsAreLargerThenTheScreen() {
lateinit var state: TvLazyListState
rule.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(Modifier.crossAxisSize(150.dp).mainAxisSize(100.dp), state) {
items(20) {
Box(Modifier.size(150.dp))
}
}
}
// Try a bunch of different start indexes
listOf(0, 5, 12).forEach {
val startIndex = it
rule.runOnIdle {
runBlocking(AutoTestFrameClock()) {
state.scrollToItem(startIndex)
state.animateScrollToItem(19)
}
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(19)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
}
}
}
@Test
fun recreatingContentLambdaTriggersItemRecomposition() {
val countState = mutableStateOf(0)
rule.setContent {
val count = countState.value
LazyColumnOrRow {
item {
BasicText(text = "Count $count")
}
}
}
rule.onNodeWithText("Count 0")
.assertIsDisplayed()
rule.runOnIdle {
countState.value++
}
rule.onNodeWithText("Count 1")
.assertIsDisplayed()
}
@Test
fun semanticsScroll_isAnimated() {
rule.mainClock.autoAdvance = false
val state = TvLazyListState()
rule.setContent {
LazyColumnOrRow(Modifier.testTag(LazyListTag), state = state) {
items(50) {
Box(Modifier.mainAxisSize(200.dp))
}
}
}
rule.waitForIdle()
assertThat(state.firstVisibleItemIndex).isEqualTo(0)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
rule.onNodeWithTag(LazyListTag).performSemanticsAction(SemanticsActions.ScrollBy) {
if (vertical) {
it(0f, 100f)
} else {
it(100f, 0f)
}
}
// We haven't advanced time yet, make sure it's still zero
assertThat(state.firstVisibleItemIndex).isEqualTo(0)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
// Advance and make sure we're partway through
// Note that we need two frames for the animation to actually happen
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeByFrame()
// The items are 200dp each, so still the first one, but offset
assertThat(state.firstVisibleItemIndex).isEqualTo(0)
assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
assertThat(state.firstVisibleItemScrollOffset).isLessThan(100)
// Finish the scroll, make sure we're at the target
rule.mainClock.advanceTimeBy(5000)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(100)
}
@Test
fun maxIntElements() {
val itemSize = with(rule.density) { 15.toDp() }
rule.setContent {
LazyColumnOrRow(
modifier = Modifier.requiredSize(itemSize * 3),
state = TvLazyListState(firstVisibleItemIndex = Int.MAX_VALUE - 3)
) {
items(Int.MAX_VALUE) {
Box(Modifier.size(itemSize).testTag("$it"))
}
}
}
rule.onNodeWithTag("${Int.MAX_VALUE - 3}").assertStartPositionInRootIsEqualTo(0.dp)
rule.onNodeWithTag("${Int.MAX_VALUE - 2}").assertStartPositionInRootIsEqualTo(itemSize)
rule.onNodeWithTag("${Int.MAX_VALUE - 1}").assertStartPositionInRootIsEqualTo(itemSize * 2)
rule.onNodeWithTag("${Int.MAX_VALUE}").assertDoesNotExist()
rule.onNodeWithTag("0").assertDoesNotExist()
}
@Test
fun scrollingByExactlyTheItemSize_switchesTheFirstVisibleItem() {
val itemSize = with(rule.density) { 30.toDp() }
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 3),
state = rememberTvLazyListState().also { state = it },
) {
items(5) {
Spacer(
Modifier.size(itemSize).testTag("$it")
)
}
}
}
state.scrollBy(itemSize)
rule.onNodeWithTag("0")
.assertIsNotDisplayed()
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(1)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
}
}
@Test
fun pointerInputScrollingIsAllowedWhenUserScrollingIsEnabled() {
val itemSize = with(rule.density) { 30.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
userScrollEnabled = true,
) {
items(5) {
Spacer(Modifier.size(itemSize).testTag("$it").focusable())
}
}
}
rule.keyPress(3)
rule.onNodeWithTag("1")
.assertStartPositionInRootIsEqualTo(0.dp)
}
@Test
fun pointerInputScrollingIsDisallowedWhenUserScrollingIsDisabled() {
val itemSize = with(rule.density) { 30.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
userScrollEnabled = false,
) {
items(5) {
Spacer(Modifier.size(itemSize).testTag("$it"))
}
}
}
rule.keyPress(1)
rule.onNodeWithTag("1")
.assertStartPositionInRootIsEqualTo(itemSize)
}
@Test
fun programmaticScrollingIsAllowedWhenUserScrollingIsDisabled() {
val itemSize = with(rule.density) { 30.toDp() }
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 3),
state = rememberTvLazyListState().also { state = it },
userScrollEnabled = false,
) {
items(5) {
Spacer(Modifier.size(itemSize).testTag("$it"))
}
}
}
state.scrollBy(itemSize)
rule.onNodeWithTag("1")
.assertStartPositionInRootIsEqualTo(0.dp)
}
@Test
fun semanticScrollingIsDisallowedWhenUserScrollingIsDisabled() {
val itemSize = with(rule.density) { 30.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
userScrollEnabled = false,
) {
items(5) {
Spacer(Modifier.size(itemSize).testTag("$it"))
}
}
}
rule.onNodeWithTag(LazyListTag)
.assert(keyNotDefined(SemanticsActions.ScrollBy))
.assert(keyNotDefined(SemanticsActions.ScrollToIndex))
// but we still have a read only scroll range property
.assert(
keyIsDefined(
if (vertical) {
SemanticsProperties.VerticalScrollAxisRange
} else {
SemanticsProperties.HorizontalScrollAxisRange
}
)
)
}
@Test
fun withMissingItems() {
val itemSize = with(rule.density) { 30.toDp() }
lateinit var state: TvLazyListState
rule.setContent {
state = rememberTvLazyListState()
LazyColumnOrRow(
modifier = Modifier.mainAxisSize(itemSize + 1.dp),
state = state
) {
items(4) {
if (it != 1) {
Box(Modifier.size(itemSize).testTag(it.toString()).focusable())
}
}
}
}
rule.onNodeWithTag("0").assertIsDisplayed()
rule.onNodeWithTag("2").assertIsDisplayed()
rule.runOnIdle {
runBlocking {
state.scrollToItem(1)
}
}
rule.onNodeWithTag("0").assertIsNotDisplayed()
rule.onNodeWithTag("2").assertIsDisplayed()
rule.onNodeWithTag("3").assertIsDisplayed()
}
@Test
fun recomposingWithNewComposedModifierObjectIsNotCausingRemeasure() {
var remeasureCount = 0
val layoutModifier = Modifier.layout { measurable, constraints ->
remeasureCount++
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
val counter = mutableStateOf(0)
rule.setContentWithTestViewConfiguration {
counter.value // just to trigger recomposition
LazyColumnOrRow(
// this will return a new object everytime causing Lazy list recomposition
// without causing remeasure
Modifier.composed { layoutModifier }
) {
items(1) {
Spacer(Modifier.size(10.dp))
}
}
}
rule.runOnIdle {
assertThat(remeasureCount).isEqualTo(1)
counter.value++
}
rule.runOnIdle {
assertThat(remeasureCount).isEqualTo(1)
}
}
@Test
fun passingNegativeItemsCountIsNotAllowed() {
var exception: Exception? = null
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow {
try {
items(-1) {
Box(Modifier)
}
} catch (e: Exception) {
exception = e
}
}
}
rule.runOnIdle {
assertThat(exception).isInstanceOf(IllegalArgumentException::class.java)
}
}
@Test
fun scrollingALotDoesntCauseLazyLayoutRecomposition() {
var recomposeCount = 0
lateinit var state: TvLazyListState
rule.setContentWithTestViewConfiguration {
state = rememberTvLazyListState()
LazyColumnOrRow(
Modifier.composed {
recomposeCount++
Modifier
},
state
) {
items(1000) {
Spacer(Modifier.size(10.dp))
}
}
}
rule.runOnIdle {
assertThat(recomposeCount).isEqualTo(1)
runBlocking {
state.scrollToItem(100)
}
}
rule.runOnIdle {
assertThat(recomposeCount).isEqualTo(1)
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun zIndexOnItemAffectsDrawingOrder() {
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier.size(6.dp).testTag(LazyListTag)
) {
items(listOf(Color.Blue, Color.Green, Color.Red)) { color ->
Box(
Modifier
.mainAxisSize(2.dp)
.crossAxisSize(6.dp)
.zIndex(if (color == Color.Green) 1f else 0f)
.drawBehind {
drawRect(
color,
topLeft = Offset(-10.dp.toPx(), -10.dp.toPx()),
size = Size(20.dp.toPx(), 20.dp.toPx())
)
})
}
}
}
rule.onNodeWithTag(LazyListTag)
.captureToImage()
.assertPixels { Color.Green }
}
@Test
fun increasingConstraintsWhenParentMaxSizeIsUsed_correctlyMaintainsThePosition() {
val state = TvLazyListState(1, 10)
var constraints by mutableStateOf(Constraints.fixed(100, 100))
rule.setContentWithTestViewConfiguration {
Layout(content = {
LazyColumnOrRow(state = state) {
items(3) {
Box(Modifier.fillParentMaxSize())
}
}
}) { measurables, _ ->
val placeable = measurables.first().measure(constraints)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(0, 0)
}
}
}
rule.runOnIdle {
constraints = Constraints.fixed(500, 500)
}
rule.runOnIdle {
assertThat(state.firstVisibleItemIndex).isEqualTo(1)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
}
}
@Test
fun usingFillParentMaxSizeOnInfinityConstraintsIsIgnored() {
rule.setContentWithTestViewConfiguration {
Layout(content = {
LazyColumnOrRow {
items(1) {
Box(
Modifier
.fillParentMaxSize(0.95f)
.testTag("item"))
}
}
}) { measurables, _ ->
val crossInfinityConstraints = if (vertical) {
Constraints(maxWidth = Constraints.Infinity, maxHeight = 100)
} else {
Constraints(maxWidth = 100, maxHeight = Constraints.Infinity)
}
val placeable = measurables.first().measure(crossInfinityConstraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
rule.onNodeWithTag("item")
.assertMainAxisSizeIsEqualTo(with(rule.density) { (100 * 0.95f).roundToInt().toDp() })
.assertCrossAxisSizeIsEqualTo(0.dp)
}
@Test
fun fillingFullSize_nextItemIsNotComposed() {
val state = TvLazyListState()
state.prefetchingEnabled = false
val itemSizePx = 5f
val itemSize = with(rule.density) { itemSizePx.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier
.testTag(LazyListTag)
.mainAxisSize(itemSize),
state = state
) {
items(3) { index ->
Box(fillParentMaxMainAxis().crossAxisSize(1.dp).testTag("$index"))
}
}
}
repeat(3) { index ->
rule.onNodeWithTag("$index")
.assertIsDisplayed()
rule.onNodeWithTag("${index + 1}")
.assertDoesNotExist()
rule.runOnIdle {
runBlocking {
state.scrollBy(itemSizePx)
}
}
}
}
@Test
fun fillingFullSize_crossAxisSizeOfVisibleItemIsUsed() {
val state = TvLazyListState()
val itemSizePx = 5f
val itemSize = with(rule.density) { itemSizePx.toDp() }
rule.setContentWithTestViewConfiguration {
LazyColumnOrRow(
Modifier
.testTag(LazyListTag)
.mainAxisSize(itemSize),
state = state
) {
items(5) { index ->
Box(fillParentMaxMainAxis().crossAxisSize(index.dp))
}
}
}
repeat(5) { index ->
rule.onNodeWithTag(LazyListTag)
.assertCrossAxisSizeIsEqualTo(index.dp)
rule.runOnIdle {
runBlocking {
state.scrollBy(itemSizePx)
}
}
}
}
// ********************* END OF TESTS *********************
// Helper functions, etc. live below here
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
}
}
internal val NeverEqualObject = object {
override fun equals(other: Any?): Boolean {
return false
}
}
private data class NotStable(val count: Int)
internal const val TestTouchSlop = 18f
internal fun IntegerSubject.isWithin1PixelFrom(expected: Int) {
isEqualTo(expected, 1)
}
internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) {
isIn(Range.closed(expected - tolerance, expected + tolerance))
}
internal fun ComposeContentTestRule.setContentWithTestViewConfiguration(
composable: @Composable () -> Unit
) {
this.setContent {
WithTouchSlop(TestTouchSlop, composable)
}
}