[go: nahoru, domu]

blob: 202b94e7c6321c8f699fffb1c26649626fa98442 [file] [log] [blame]
/*
* Copyright 2023 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.lazy.staggeredgrid
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.lazy.list.getValueAtFrame
import androidx.compose.foundation.lazy.list.getVelocityAtFrame
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import kotlin.math.roundToInt
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@OptIn(ExperimentalFoundationApi::class)
@LargeTest
@RunWith(Parameterized::class)
class LazyStaggeredGridAnimateItemPlacementTest(private val config: Config) {
private val isVertical: Boolean get() = config.isVertical
private val reverseLayout: Boolean get() = config.reverseLayout
@get:Rule
val rule = createComposeRule()
// the numbers should be divisible by 8 to avoid the rounding issues as we run 4 or 8 frames
// of the animation.
private val itemSize: Float = 40f
private var itemSizeDp: Dp = Dp.Infinity
private val itemSize2: Float = 24f
private var itemSize2Dp: Dp = Dp.Infinity
private val itemSize3: Float = 16f
private var itemSize3Dp: Dp = Dp.Infinity
private val containerSize: Float = itemSize * 5
private var containerSizeDp: Dp = Dp.Infinity
private val spacing: Float = 8f
private var spacingDp: Dp = Dp.Infinity
private val itemSizePlusSpacing = itemSize + spacing
private var itemSizePlusSpacingDp = Dp.Infinity
private lateinit var state: LazyStaggeredGridState
@Before
fun before() {
rule.mainClock.autoAdvance = false
with(rule.density) {
itemSizeDp = itemSize.toDp()
itemSize2Dp = itemSize2.toDp()
itemSize3Dp = itemSize3.toDp()
containerSizeDp = containerSize.toDp()
spacingDp = spacing.toDp()
itemSizePlusSpacingDp = itemSizePlusSpacing.toDp()
}
}
@Test
fun reorderTwoItems() {
var list by mutableStateOf(listOf(0, 1))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize)
)
rule.runOnUiThread {
list = listOf(1, 0)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f + itemSize * fraction),
1 to AxisOffset(0f, itemSize - itemSize * fraction),
fraction = fraction
)
}
}
@Test
fun reorderTwoByTwoItems() {
var list by mutableStateOf(listOf(0, 1, 2, 3))
rule.setContent {
LazyStaggeredGrid(2) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize)
)
rule.runOnUiThread {
list = listOf(3, 2, 1, 0)
}
onAnimationFrame { fraction ->
val increasing = 0 + itemSize * fraction
val decreasing = itemSize - itemSize * fraction
assertPositions(
0 to AxisOffset(increasing, increasing),
1 to AxisOffset(decreasing, increasing),
2 to AxisOffset(increasing, decreasing),
3 to AxisOffset(decreasing, decreasing),
fraction = fraction
)
}
}
@Test
fun reorderTwoItems_layoutInfoHasFinalPositions() {
var list by mutableStateOf(listOf(0, 1, 2, 3))
rule.setContent {
LazyStaggeredGrid(2) {
items(list, key = { it }) {
Item(it)
}
}
}
assertLayoutInfoPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize)
)
rule.runOnUiThread {
list = listOf(3, 2, 1, 0)
}
onAnimationFrame {
// fraction doesn't affect the offsets in layout info
assertLayoutInfoPositions(
3 to AxisOffset(0f, 0f),
2 to AxisOffset(itemSize, 0f),
1 to AxisOffset(0f, itemSize),
0 to AxisOffset(itemSize, itemSize)
)
}
}
@Test
fun reorderFirstAndLastItems() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2),
3 to AxisOffset(0f, itemSize * 3),
4 to AxisOffset(0f, itemSize * 4)
)
rule.runOnUiThread {
list = listOf(4, 1, 2, 3, 0)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f + itemSize * 4 * fraction),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2),
3 to AxisOffset(0f, itemSize * 3),
4 to AxisOffset(0f, itemSize * 4 - itemSize * 4 * fraction),
fraction = fraction
)
}
}
@Test
fun moveFirstItemToEndCausingAllItemsToAnimate() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
rule.setContent {
LazyStaggeredGrid(2) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize),
4 to AxisOffset(0f, itemSize * 2),
5 to AxisOffset(itemSize, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(1, 2, 3, 4, 5, 0)
}
onAnimationFrame { fraction ->
val increasingX = 0 + itemSize * fraction
val decreasingX = itemSize - itemSize * fraction
assertPositions(
0 to AxisOffset(increasingX, 0f + itemSize * 2 * fraction),
1 to AxisOffset(decreasingX, 0f),
2 to AxisOffset(increasingX, itemSize - itemSize * fraction),
3 to AxisOffset(decreasingX, itemSize),
4 to AxisOffset(increasingX, itemSize * 2 - itemSize * fraction),
5 to AxisOffset(decreasingX, itemSize * 2),
fraction = fraction
)
}
}
@Test
fun itemSizeChangeAnimatesNextItems() {
var size by mutableStateOf(itemSizeDp)
rule.setContent {
LazyStaggeredGrid(1, minSize = itemSizeDp * 5, maxSize = itemSizeDp * 5) {
items(listOf(0, 1, 2, 3), key = { it }) {
Item(it, size = if (it == 1) size else itemSizeDp)
}
}
}
rule.runOnUiThread {
size = itemSizeDp * 2
}
rule.mainClock.advanceTimeByFrame()
rule.onNodeWithTag("1")
.assertMainAxisSizeIsEqualTo(size)
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2 + itemSize * fraction),
3 to AxisOffset(0f, itemSize * 3 + itemSize * fraction),
fraction = fraction
)
}
}
@Test
fun onlyItemsWithModifierAnimates() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it, animSpec = if (it == 1 || it == 3) AnimSpec else null)
}
}
}
rule.runOnUiThread {
list = listOf(1, 2, 3, 4, 0)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, itemSize * 4),
1 to AxisOffset(0f, itemSize - itemSize * fraction),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(0f, itemSize * 3 - itemSize * fraction),
4 to AxisOffset(0f, itemSize * 3),
fraction = fraction
)
}
}
@Test
fun animationsWithDifferentDurations() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
val duration = if (it == 1 || it == 3) Duration * 2 else Duration
Item(it, animSpec = tween(duration.toInt(), easing = LinearEasing))
}
}
}
rule.runOnUiThread {
list = listOf(1, 2, 3, 4, 0)
}
onAnimationFrame(duration = Duration * 2) { fraction ->
val shorterAnimFraction = (fraction * 2).coerceAtMost(1f)
assertPositions(
0 to AxisOffset(0f, 0 + itemSize * 4 * shorterAnimFraction),
1 to AxisOffset(0f, itemSize - itemSize * fraction),
2 to AxisOffset(0f, itemSize * 2 - itemSize * shorterAnimFraction),
3 to AxisOffset(0f, itemSize * 3 - itemSize * fraction),
4 to AxisOffset(0f, itemSize * 4 - itemSize * shorterAnimFraction),
fraction = fraction
)
}
}
@Test
fun multipleChildrenPerItem() {
var list by mutableStateOf(listOf(0, 2))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it)
Item(it + 1)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(0f, itemSize)
)
rule.runOnUiThread {
list = listOf(2, 0)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0 + itemSize * fraction),
1 to AxisOffset(0f, 0 + itemSize * fraction),
2 to AxisOffset(0f, itemSize - itemSize * fraction),
3 to AxisOffset(0f, itemSize - itemSize * fraction),
fraction = fraction
)
}
}
@Test
fun multipleChildrenPerItemSomeDoNotAnimate() {
var list by mutableStateOf(listOf(0, 2))
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it)
Item(it + 1, animSpec = null)
}
}
}
rule.runOnUiThread {
list = listOf(2, 0)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0 + itemSize * fraction),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize - itemSize * fraction),
3 to AxisOffset(0f, 0f),
fraction = fraction
)
}
}
@Test
fun animateSpacingChange() {
var currentSpacing by mutableStateOf(0.dp)
rule.setContent {
LazyStaggeredGrid(
1,
spacing = currentSpacing
) {
items(listOf(0, 1), key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
)
rule.runOnUiThread {
currentSpacing = spacingDp
}
rule.mainClock.advanceTimeByFrame()
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize + spacing * fraction),
fraction = fraction
)
}
}
@Test
fun moveItemToTheBottomOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
val gridSize = itemSize * 3
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(2, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize),
4 to AxisOffset(0f, itemSize * 2),
5 to AxisOffset(itemSize, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
}
onAnimationFrame { fraction ->
// item 1 moves to and item 8 moves from `gridSize`, right after the end edge
val item1Offset = AxisOffset(itemSize, 0 + gridSize * fraction)
val item8Offset =
AxisOffset(itemSize, gridSize - gridSize * fraction)
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
if (item1Offset.mainAxis < itemSize * 3) {
add(1 to item1Offset)
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
add(2 to AxisOffset(0f, itemSize))
add(3 to AxisOffset(itemSize, itemSize))
add(4 to AxisOffset(0f, itemSize * 2))
add(5 to AxisOffset(itemSize, itemSize * 2))
if (item8Offset.mainAxis < itemSize * 3) {
add(8 to item8Offset)
} else {
rule.onNodeWithTag("8").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveItemToTheTopOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
rule.setContent {
LazyStaggeredGrid(2, maxSize = itemSizeDp * 3, startIndex = 6) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
6 to AxisOffset(0f, 0f),
7 to AxisOffset(itemSize, 0f),
8 to AxisOffset(0f, itemSize),
9 to AxisOffset(itemSize, itemSize),
10 to AxisOffset(0f, itemSize * 2),
11 to AxisOffset(itemSize, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
}
onAnimationFrame { fraction ->
// item 1 moves from and item 8 moves to `0 - itemSize`, right before the start edge
val item8Offset = AxisOffset(0f, itemSize - itemSize * 2 * fraction)
val item1Offset = AxisOffset(0f, -itemSize + itemSize * 2 * fraction)
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (item1Offset.mainAxis > -itemSize) {
add(1 to item1Offset)
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
add(6 to AxisOffset(0f, 0f))
add(7 to AxisOffset(itemSize, 0f))
if (item8Offset.mainAxis > -itemSize) {
add(8 to item8Offset)
} else {
rule.onNodeWithTag("8").assertIsNotDisplayed()
}
add(9 to AxisOffset(itemSize, itemSize))
add(10 to AxisOffset(0f, itemSize * 2))
add(11 to AxisOffset(itemSize, itemSize * 2))
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveFirstItemToEndCausingAllItemsToAnimate_withSpacing() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7))
rule.setContent {
LazyStaggeredGrid(2, spacing = spacingDp) {
items(list, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
list = listOf(1, 2, 3, 4, 5, 6, 7, 0)
}
onAnimationFrame { fraction ->
val increasingX = fraction * itemSize
val decreasingX = itemSize - itemSize * fraction
assertPositions(
0 to AxisOffset(increasingX, itemSizePlusSpacing * 3 * fraction),
1 to AxisOffset(decreasingX, 0f),
2 to AxisOffset(
increasingX,
itemSizePlusSpacing - itemSizePlusSpacing * fraction
),
3 to AxisOffset(decreasingX, itemSizePlusSpacing),
4 to AxisOffset(
increasingX,
itemSizePlusSpacing * 2 - itemSizePlusSpacing * fraction
),
5 to AxisOffset(decreasingX, itemSizePlusSpacing * 2),
6 to AxisOffset(
increasingX,
itemSizePlusSpacing * 3 - itemSizePlusSpacing * fraction
),
7 to AxisOffset(decreasingX, itemSizePlusSpacing * 3),
fraction = fraction
)
}
}
@Test
fun moveItemToTheBottomOutsideOfBounds_withSpacing() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
val gridSize = itemSize * 3 + spacing * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(
2,
maxSize = gridSizeDp,
spacing = spacingDp
) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSizePlusSpacing),
3 to AxisOffset(itemSize, itemSizePlusSpacing),
4 to AxisOffset(0f, itemSizePlusSpacing * 2),
5 to AxisOffset(itemSize, itemSizePlusSpacing * 2)
)
rule.runOnUiThread {
list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9)
}
onAnimationFrame { fraction ->
// item 1 moves to and item 8 moves from `gridSize`, right after the end edge
val item1Offset = AxisOffset(itemSize, gridSize * fraction)
val item8Offset = AxisOffset(itemSize, gridSize - gridSize * fraction)
val screenSize = itemSize * 3 + spacing * 2
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
if (item1Offset.mainAxis < screenSize) {
add(1 to item1Offset)
}
add(2 to AxisOffset(0f, itemSizePlusSpacing))
add(3 to AxisOffset(itemSize, itemSizePlusSpacing))
add(4 to AxisOffset(0f, itemSizePlusSpacing * 2))
add(5 to AxisOffset(itemSize, itemSizePlusSpacing * 2))
if (item8Offset.mainAxis < screenSize) {
add(8 to item8Offset)
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveItemToTheTopOutsideOfBounds_withSpacing() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
rule.setContent {
LazyStaggeredGrid(
2,
maxSize = itemSizeDp * 3 + spacingDp * 2,
spacing = spacingDp,
startIndex = 4
) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
4 to AxisOffset(0f, 0f),
5 to AxisOffset(itemSize, 0f),
6 to AxisOffset(0f, itemSizePlusSpacing),
7 to AxisOffset(itemSize, itemSizePlusSpacing),
8 to AxisOffset(0f, itemSizePlusSpacing * 2),
9 to AxisOffset(itemSize, itemSizePlusSpacing * 2)
)
rule.runOnUiThread {
list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
}
onAnimationFrame { fraction ->
// item 8 moves to and item 1 moves from `-itemSize`, right before the start edge
val item1Offset = AxisOffset(
0f,
-itemSize + (itemSize + itemSizePlusSpacing * 2) * fraction
)
val item8Offset = AxisOffset(
0f,
itemSizePlusSpacing * 2 -
(itemSize + itemSizePlusSpacing * 2) * fraction
)
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (item1Offset.mainAxis > -itemSize) {
add(1 to item1Offset)
}
add(4 to AxisOffset(0f, 0f))
add(5 to AxisOffset(itemSize, 0f))
add(6 to AxisOffset(0f, itemSizePlusSpacing))
add(7 to AxisOffset(itemSize, itemSizePlusSpacing))
if (item8Offset.mainAxis > -itemSize) {
add(8 to item8Offset)
}
add(9 to AxisOffset(itemSize, itemSizePlusSpacing * 2))
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveItemToTheTopOutsideOfBounds_differentSizes() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
rule.setContent {
LazyStaggeredGrid(2, maxSize = itemSize2Dp * 2, startIndex = 6) {
items(list, key = { it }) {
val height = when (it) {
2 -> itemSize3Dp
6, 9 -> itemSize2Dp
7 -> itemSize3Dp
8 -> itemSizeDp
else -> itemSizeDp
}
Item(it, size = height)
}
}
}
val item2Size = itemSize3
val item6Size = itemSize2
val item7Size = itemSize3
val item8Size = itemSize
assertPositions(
6 to AxisOffset(0f, 0f),
7 to AxisOffset(itemSize, 0f),
8 to AxisOffset(itemSize, item7Size),
9 to AxisOffset(0f, item6Size)
)
rule.runOnUiThread {
// swap 8 and 2
list = listOf(0, 1, 8, 3, 4, 5, 6, 7, 2, 9, 10, 11)
}
onAnimationFrame { fraction ->
rule.onNodeWithTag("3").assertDoesNotExist()
rule.onNodeWithTag("4").assertDoesNotExist()
rule.onNodeWithTag("5").assertDoesNotExist()
// item 2 moves from and item 8 moves to `0 - item size`, right before the start edge
val startItem2Offset = -item2Size
val item2Offset =
startItem2Offset + (item7Size - startItem2Offset) * fraction
val endItem8Offset = -item8Size
val item8Offset = item7Size - (item7Size - endItem8Offset) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (item8Offset > -item8Size) {
add(8 to AxisOffset(itemSize, item8Offset))
} else {
rule.onNodeWithTag("8").assertIsNotDisplayed()
}
add(6 to AxisOffset(0f, 0f))
add(7 to AxisOffset(itemSize, 0f))
if (item2Offset > -item2Size) {
add(2 to AxisOffset(itemSize, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
add(9 to AxisOffset(0f, item6Size))
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveItemToTheBottomOutsideOfBounds_differentSizes() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8))
val gridSize = itemSize2 * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(2, maxSize = gridSizeDp) {
items(list, key = { it }) {
val height = when (it) {
0, 3 -> itemSize2Dp
1 -> itemSize3Dp
2 -> itemSizeDp
8 -> itemSize3Dp
else -> itemSizeDp
}
Item(it, size = height)
}
}
}
val item0Size = itemSize2
val item1Size = itemSize3
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(itemSize, item1Size),
3 to AxisOffset(0f, item0Size)
)
rule.runOnUiThread {
list = listOf(0, 1, 8, 3, 4, 5, 6, 7, 2, 9, 10, 11)
}
onAnimationFrame { fraction ->
// item 8 moves from and item 2 moves to `gridSize`, right after the end edge
val startItem8Offset = gridSize
val endItem2Offset = gridSize
val item2Offset =
item1Size + (endItem2Offset - item1Size) * fraction
val item8Offset =
startItem8Offset - (startItem8Offset - item1Size) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
add(1 to AxisOffset(itemSize, 0f))
if (item8Offset < gridSize) {
add(8 to AxisOffset(itemSize, item8Offset))
} else {
rule.onNodeWithTag("8").assertIsNotDisplayed()
}
add(3 to AxisOffset(0f, item0Size))
if (item2Offset < gridSize) {
add(2 to AxisOffset(itemSize, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveItemToEndCausingNextItemsToAnimate_withContentPadding() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val rawStartPadding = 8f
val rawEndPadding = 12f
val (startPaddingDp, endPaddingDp) = with(rule.density) {
rawStartPadding.toDp() to rawEndPadding.toDp()
}
rule.setContent {
LazyStaggeredGrid(1, startPadding = startPaddingDp, endPadding = endPaddingDp) {
items(list, key = { it }) {
Item(it)
}
}
}
val startPadding = if (reverseLayout) rawEndPadding else rawStartPadding
assertPositions(
0 to AxisOffset(0f, startPadding),
1 to AxisOffset(0f, startPadding + itemSize),
2 to AxisOffset(0f, startPadding + itemSize * 2),
3 to AxisOffset(0f, startPadding + itemSize * 3),
4 to AxisOffset(0f, startPadding + itemSize * 4),
)
rule.runOnUiThread {
list = listOf(0, 2, 3, 4, 1)
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, startPadding),
1 to AxisOffset(
0f,
startPadding + itemSize + itemSize * 3 * fraction
),
2 to AxisOffset(
0f,
startPadding + itemSize * 2 - itemSize * fraction
),
3 to AxisOffset(
0f,
startPadding + itemSize * 3 - itemSize * fraction
),
4 to AxisOffset(
0f,
startPadding + itemSize * 4 - itemSize * fraction
),
fraction = fraction
)
}
}
@Test
fun reorderFirstAndLastItems_noNewLayoutInfoProduced() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
var measurePasses = 0
rule.setContent {
LazyStaggeredGrid(1) {
items(list, key = { it }) {
Item(it)
}
}
LaunchedEffect(Unit) {
snapshotFlow { state.layoutInfo }
.collect {
measurePasses++
}
}
}
rule.runOnUiThread {
list = listOf(4, 1, 2, 3, 0)
}
var startMeasurePasses = Int.MIN_VALUE
onAnimationFrame { fraction ->
if (fraction == 0f) {
startMeasurePasses = measurePasses
}
}
rule.mainClock.advanceTimeByFrame()
// new layoutInfo is produced on every remeasure of Lazy lists.
// but we want to avoid remeasuring and only do relayout on each animation frame.
// two extra measures are possible as we switch inProgress flag.
assertThat(measurePasses).isAtMost(startMeasurePasses + 2)
}
@Test
fun noAnimationWhenScrolledToOtherPosition() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollToItem(0, (itemSize / 2).roundToInt())
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(0f, itemSize / 2),
2 to AxisOffset(0f, itemSize * 3 / 2),
3 to AxisOffset(0f, itemSize * 5 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollForwardBySmallOffset() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize / 2f)
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(0f, itemSize / 2),
2 to AxisOffset(0f, itemSize * 3 / 2),
3 to AxisOffset(0f, itemSize * 5 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollBackwardBySmallOffset() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3, startIndex = 2) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(-itemSize / 2f)
}
}
onAnimationFrame { fraction ->
assertPositions(
1 to AxisOffset(0f, -itemSize / 2),
2 to AxisOffset(0f, itemSize / 2),
3 to AxisOffset(0f, itemSize * 3 / 2),
4 to AxisOffset(0f, itemSize * 5 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollForwardByLargeOffset() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
2 to AxisOffset(0f, -itemSize / 2),
3 to AxisOffset(0f, itemSize / 2),
4 to AxisOffset(0f, itemSize * 3 / 2),
5 to AxisOffset(0f, itemSize * 5 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollBackwardByLargeOffset() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3, startIndex = 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(-itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(0f, itemSize / 2),
2 to AxisOffset(0f, itemSize * 3 / 2),
3 to AxisOffset(0f, itemSize * 5 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollForwardByLargeOffset_differentSizes() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it, size = if (it % 2 == 0) itemSizeDp else itemSize2Dp)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize + itemSize2 + itemSize / 2f)
}
}
onAnimationFrame { fraction ->
assertPositions(
2 to AxisOffset(0f, -itemSize / 2),
3 to AxisOffset(0f, itemSize / 2),
4 to AxisOffset(0f, itemSize2 + itemSize / 2),
5 to AxisOffset(0f, itemSize2 + itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollBackwardByLargeOffset_differentSizes() {
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3, startIndex = 3) {
items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
Item(it, size = if (it % 2 == 0) itemSizeDp else itemSize2Dp)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(-(itemSize + itemSize2 + itemSize / 2f))
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(0f, itemSize / 2),
2 to AxisOffset(0f, itemSize2 + itemSize / 2),
3 to AxisOffset(0f, itemSize2 + itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollForwardByLargeOffset_multipleCells() {
rule.setContent {
LazyStaggeredGrid(3, maxSize = itemSizeDp * 2) {
items(List(20) { it }, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(itemSize * 2, 0f),
3 to AxisOffset(0f, itemSize),
4 to AxisOffset(itemSize, itemSize),
5 to AxisOffset(itemSize * 2, itemSize)
)
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
6 to AxisOffset(0f, -itemSize / 2),
7 to AxisOffset(itemSize, -itemSize / 2),
8 to AxisOffset(itemSize * 2, -itemSize / 2),
9 to AxisOffset(0f, itemSize / 2),
10 to AxisOffset(itemSize, itemSize / 2),
11 to AxisOffset(itemSize * 2, itemSize / 2),
12 to AxisOffset(0f, itemSize * 3 / 2),
13 to AxisOffset(itemSize, itemSize * 3 / 2),
14 to AxisOffset(itemSize * 2, itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollBackwardByLargeOffset_multipleCells() {
rule.setContent {
LazyStaggeredGrid(3, maxSize = itemSizeDp * 2, startIndex = 9) {
items(List(20) { it }, key = { it }) {
Item(it)
}
}
}
assertPositions(
9 to AxisOffset(0f, 0f),
10 to AxisOffset(itemSize, 0f),
11 to AxisOffset(itemSize * 2, 0f),
12 to AxisOffset(0f, itemSize),
13 to AxisOffset(itemSize, itemSize),
14 to AxisOffset(itemSize * 2, itemSize)
)
rule.runOnUiThread {
runBlocking {
state.scrollBy(-itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(itemSize, -itemSize / 2),
2 to AxisOffset(itemSize * 2, -itemSize / 2),
3 to AxisOffset(0f, itemSize / 2),
4 to AxisOffset(itemSize, itemSize / 2),
5 to AxisOffset(itemSize * 2, itemSize / 2),
6 to AxisOffset(0f, itemSize * 3 / 2),
7 to AxisOffset(itemSize, itemSize * 3 / 2),
8 to AxisOffset(itemSize * 2, itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollForwardByLargeOffset_differentSpans() {
rule.setContent {
LazyStaggeredGrid(2, maxSize = itemSizeDp * 2) {
items(
List(10) { it },
key = { it },
span = {
if (it == 6) {
StaggeredGridItemSpan.FullLine
} else {
StaggeredGridItemSpan.SingleLane
}
}
) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize),
)
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
4 to AxisOffset(0f, -itemSize / 2),
5 to AxisOffset(itemSize, -itemSize / 2),
6 to AxisOffset(0f, itemSize / 2), // 3 spans
7 to AxisOffset(0f, itemSize * 3 / 2),
8 to AxisOffset(itemSize, itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenScrollBackwardByLargeOffset_differentSpans() {
rule.setContent {
LazyStaggeredGrid(2, maxSize = itemSizeDp * 2) {
items(
List(10) { it },
key = { it },
span = {
if (it == 2) {
StaggeredGridItemSpan.FullLine
} else {
StaggeredGridItemSpan.SingleLane
}
}
) {
Item(it)
}
}
}
rule.runOnUiThread {
runBlocking {
state.scrollBy(itemSize * 3f)
}
}
assertPositions(
5 to AxisOffset(0f, 0f),
6 to AxisOffset(itemSize, 0f),
7 to AxisOffset(0f, itemSize),
8 to AxisOffset(itemSize, itemSize),
)
rule.runOnUiThread {
runBlocking {
state.scrollBy(-itemSize * 2.5f)
}
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, -itemSize / 2),
1 to AxisOffset(itemSize, -itemSize / 2),
2 to AxisOffset(0f, itemSize / 2), // 3 spans
3 to AxisOffset(0f, itemSize * 3 / 2),
4 to AxisOffset(itemSize, itemSize * 3 / 2),
fraction = fraction
)
}
}
@Test
fun animatingItemWithPreviousIndexLargerThanTheNewItemCount() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7))
val gridSize = itemSize * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(1, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
assertLayoutInfoPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
)
rule.runOnUiThread {
list = listOf(0, 6)
}
onAnimationFrame { fraction ->
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
val item6MainAxis = gridSize - (gridSize - itemSize) * fraction
if (item6MainAxis < gridSize) {
add(6 to AxisOffset(0f, item6MainAxis))
} else {
rule.onNodeWithTag("6").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun animatingItemsWithPreviousIndexLargerThanTheNewItemCount_differentSpans() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6))
val gridSize = itemSize * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(2, maxSize = gridSizeDp) {
items(list, key = { it }, span = {
if (it == 6) {
StaggeredGridItemSpan.FullLine
} else {
StaggeredGridItemSpan.SingleLane
}
}) {
Item(it)
}
}
}
assertLayoutInfoPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize)
)
rule.runOnUiThread {
list = listOf(0, 4, 6)
}
onAnimationFrame { fraction ->
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
val item4MainAxis = gridSize - gridSize * fraction
if (item4MainAxis < gridSize) {
add(
4 to AxisOffset(itemSize, item4MainAxis)
)
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
}
val item6MainAxis = gridSize - (gridSize - itemSize) * fraction
if (item6MainAxis < gridSize) {
add(
6 to AxisOffset(0f, item6MainAxis)
)
} else {
rule.onNodeWithTag("6").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun itemWithSpecsIsMovingOut() {
var list by mutableStateOf(listOf(0, 1, 2, 3))
val gridSize = itemSize * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(1, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it, animSpec = if (it == 1) AnimSpec else null)
}
}
}
rule.runOnUiThread {
list = listOf(0, 2, 3, 1)
}
onAnimationFrame { fraction ->
// item 1 moves to `gridSize`
val item1Offset = itemSize + (gridSize - itemSize) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
if (item1Offset < gridSize) {
add(1 to AxisOffset(0f, item1Offset))
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheTopOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3f, startIndex = 3) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
3 to AxisOffset(0f, 0f),
4 to AxisOffset(0f, itemSize),
5 to AxisOffset(0f, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 4, 5, 3, 1, 2)
}
onAnimationFrame { fraction ->
// item 2 moves from and item 5 moves to `-itemSize`, right before the start edge
val item2Offset = -itemSize + itemSize * 3 * fraction
val item5Offset = itemSize * 2 - itemSize * 3 * fraction
// item 1 moves from and item 4 moves to `-itemSize * 2`, right before item 2
val item1Offset = -itemSize * 2 + itemSize * 3 * fraction
val item4Offset = itemSize - itemSize * 3 * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (item1Offset > -itemSize) {
add(1 to AxisOffset(0f, item1Offset))
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
if (item2Offset > -itemSize) {
add(2 to AxisOffset(0f, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
add(3 to AxisOffset(0f, 0f))
if (item4Offset > -itemSize) {
add(4 to AxisOffset(0f, item4Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
}
if (item5Offset > -itemSize) {
add(5 to AxisOffset(0f, item5Offset))
} else {
rule.onNodeWithTag("5").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheTopOutsideOfBounds_withReordering() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 3f, startIndex = 3) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
3 to AxisOffset(0f, 0f),
4 to AxisOffset(0f, itemSize),
5 to AxisOffset(0f, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 5, 4, 3, 2, 1)
}
onAnimationFrame { fraction ->
// item 2 moves from and item 4 moves to `-itemSize`, right before the start edge
val item2Offset = -itemSize + itemSize * 2 * fraction
val item4Offset = itemSize - itemSize * 2 * fraction
// item 1 moves from and item 5 moves to `-itemSize * 2`, right before item 2
val item1Offset = -itemSize * 2 + itemSize * 4 * fraction
val item5Offset = itemSize * 2 - itemSize * 4 * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (item1Offset > -itemSize) {
add(1 to AxisOffset(0f, item1Offset))
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
if (item2Offset > -itemSize) {
add(2 to AxisOffset(0f, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
add(3 to AxisOffset(0f, 0f))
if (item4Offset > -itemSize) {
add(4 to AxisOffset(0f, item4Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
}
if (item5Offset > -itemSize) {
add(5 to AxisOffset(0f, item5Offset))
} else {
rule.onNodeWithTag("5").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheTopOutsideOfBounds_itemsOfDifferentLanes() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
rule.setContent {
LazyStaggeredGrid(2, maxSize = itemSizeDp * 2f, startIndex = 2) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
2 to AxisOffset(0f, 0f),
3 to AxisOffset(itemSize, 0f),
4 to AxisOffset(0f, itemSize),
5 to AxisOffset(itemSize, itemSize)
)
rule.runOnUiThread {
list = listOf(4, 5, 2, 3, 0, 1)
}
onAnimationFrame { fraction ->
// items 0 and 2 moves from and items 4 and 5 moves to `-itemSize`,
// right before the start edge
val items0and1Offset = -itemSize + itemSize * 2 * fraction
val items4and5Offset = itemSize - itemSize * 2 * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
if (items0and1Offset > -itemSize) {
add(0 to AxisOffset(0f, items0and1Offset))
add(1 to AxisOffset(itemSize, items0and1Offset))
} else {
rule.onNodeWithTag("0").assertIsNotDisplayed()
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
add(2 to AxisOffset(0f, 0f))
add(3 to AxisOffset(itemSize, 0f))
if (items4and5Offset > -itemSize) {
add(4 to AxisOffset(0f, items4and5Offset))
add(5 to AxisOffset(itemSize, items4and5Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
rule.onNodeWithTag("5").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheBottomOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val gridSize = itemSize * 3
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(1, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 3, 4, 1, 2)
}
onAnimationFrame { fraction ->
// item 1 moves to and item 3 moves from `gridSize`, right after the end edge
val item1Offset = itemSize + (gridSize - itemSize) * fraction
val item3Offset = gridSize - (gridSize - itemSize) * fraction
// item 2 moves to and item 4 moves from `gridSize + itemSize`, right after item 4
val item2Offset = itemSize * 2 + (gridSize - itemSize) * fraction
val item4Offset = gridSize + itemSize - (gridSize - itemSize) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
if (item1Offset < gridSize) {
add(1 to AxisOffset(0f, item1Offset))
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
if (item2Offset < gridSize) {
add(2 to AxisOffset(0f, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
if (item3Offset < gridSize) {
add(3 to AxisOffset(0f, item3Offset))
} else {
rule.onNodeWithTag("3").assertIsNotDisplayed()
}
if (item4Offset < gridSize) {
add(4 to AxisOffset(0f, item4Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheBottomOutsideOfBounds_withReordering() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val gridSize = itemSize * 3
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(1, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2)
)
rule.runOnUiThread {
list = listOf(0, 4, 3, 2, 1)
}
onAnimationFrame { fraction ->
// item 2 moves to and item 3 moves from `gridSize`, right after the end edge
val item2Offset = itemSize * 2 + (gridSize - itemSize * 2) * fraction
val item3Offset = gridSize - (gridSize - itemSize * 2) * fraction
// item 1 moves to and item 4 moves from `gridSize + itemSize`, right after item 4
val item1Offset = itemSize + (gridSize + itemSize - itemSize) * fraction
val item4Offset =
gridSize + itemSize - (gridSize + itemSize - itemSize) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
if (item1Offset < gridSize) {
add(1 to AxisOffset(0f, item1Offset))
} else {
rule.onNodeWithTag("1").assertIsNotDisplayed()
}
if (item2Offset < gridSize) {
add(2 to AxisOffset(0f, item2Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
if (item3Offset < gridSize) {
add(3 to AxisOffset(0f, item3Offset))
} else {
rule.onNodeWithTag("3").assertIsNotDisplayed()
}
if (item4Offset < gridSize) {
add(4 to AxisOffset(0f, item4Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun moveTwoItemsToTheBottomOutsideOfBounds_itemsOfDifferentLanes() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
val gridSize = itemSize * 2
val gridSizeDp = with(rule.density) { gridSize.toDp() }
rule.setContent {
LazyStaggeredGrid(2, maxSize = gridSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f),
2 to AxisOffset(0f, itemSize),
3 to AxisOffset(itemSize, itemSize)
)
rule.runOnUiThread {
list = listOf(0, 1, 4, 5, 2, 3)
}
onAnimationFrame { fraction ->
// items 4 and 5 moves from and items 2 and 3 moves to `gridSize`,
// right before the start edge
val items4and5Offset = gridSize - (gridSize - itemSize) * fraction
val items2and3Offset = itemSize + (gridSize - itemSize) * fraction
val expected = mutableListOf<Pair<Any, Offset>>().apply {
add(0 to AxisOffset(0f, 0f))
add(1 to AxisOffset(itemSize, 0f))
if (items2and3Offset < gridSize) {
add(2 to AxisOffset(0f, items2and3Offset))
add(3 to AxisOffset(itemSize, items2and3Offset))
} else {
rule.onNodeWithTag("2").assertIsNotDisplayed()
rule.onNodeWithTag("3").assertIsNotDisplayed()
}
if (items4and5Offset < gridSize) {
add(4 to AxisOffset(0f, items4and5Offset))
add(5 to AxisOffset(itemSize, items4and5Offset))
} else {
rule.onNodeWithTag("4").assertIsNotDisplayed()
rule.onNodeWithTag("5").assertIsNotDisplayed()
}
}
assertPositions(
expected = expected.toTypedArray(),
fraction = fraction
)
}
}
@Test
fun noAnimationWhenParentSizeShrinks() {
var size by mutableStateOf(itemSizeDp * 3)
rule.setContent {
LazyStaggeredGrid(1, maxSize = size) {
items(listOf(0, 1, 2), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
size = itemSizeDp * 2
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
fraction = fraction
)
rule.onNodeWithTag("2").assertIsNotDisplayed()
}
}
@Test
fun noAnimationWhenParentSizeExpands() {
var size by mutableStateOf(itemSizeDp * 2)
rule.setContent {
LazyStaggeredGrid(1, maxSize = size) {
items(listOf(0, 1, 2), key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
size = itemSizeDp * 3
}
onAnimationFrame { fraction ->
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2),
fraction = fraction
)
}
}
@Test
fun scrollIsAffectingItemsMovingWithinViewport() {
var list by mutableStateOf(listOf(0, 1, 2, 3))
val scrollDelta = spacing
rule.setContent {
LazyStaggeredGrid(1, maxSize = itemSizeDp * 2) {
items(list, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
list = listOf(0, 2, 1, 3)
}
onAnimationFrame { fraction ->
if (fraction == 0f) {
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
2 to AxisOffset(0f, itemSize * 2),
fraction = fraction
)
rule.runOnUiThread {
runBlocking { state.scrollBy(scrollDelta) }
}
}
assertPositions(
0 to AxisOffset(0f, -scrollDelta),
1 to AxisOffset(0f, itemSize - scrollDelta + itemSize * fraction),
2 to AxisOffset(0f, itemSize * 2 - scrollDelta - itemSize * fraction),
fraction = fraction
)
}
}
@Test
fun scrollIsNotAffectingItemMovingToTheBottomOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val scrollDelta = spacing
val containerSizeDp = itemSizeDp * 2
val containerSize = itemSize * 2
rule.setContent {
LazyStaggeredGrid(1, maxSize = containerSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
list = listOf(0, 4, 2, 3, 1)
}
onAnimationFrame { fraction ->
if (fraction == 0f) {
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
fraction = fraction
)
rule.runOnUiThread {
runBlocking { state.scrollBy(scrollDelta) }
}
}
assertPositions(
0 to AxisOffset(0f, -scrollDelta),
1 to AxisOffset(0f, itemSize + (containerSize - itemSize) * fraction),
fraction = fraction
)
}
}
@Test
fun scrollIsNotAffectingItemMovingToTheTopOutsideOfBounds() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val scrollDelta = -spacing
val containerSizeDp = itemSizeDp * 2
rule.setContent {
LazyStaggeredGrid(1, maxSize = containerSizeDp, startIndex = 2) {
items(list, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
list = listOf(3, 0, 1, 2, 4)
}
onAnimationFrame { fraction ->
if (fraction == 0f) {
assertPositions(
2 to AxisOffset(0f, 0f),
3 to AxisOffset(0f, itemSize),
fraction = fraction
)
rule.runOnUiThread {
runBlocking { state.scrollBy(scrollDelta) }
}
}
assertPositions(
2 to AxisOffset(0f, -scrollDelta),
3 to AxisOffset(0f, itemSize - (itemSize * 2 * fraction)),
fraction = fraction
)
}
}
@Test
fun afterScrollingEnoughToReachNewPositionScrollDeltasStartAffectingPosition() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
val containerSizeDp = itemSizeDp * 2
val scrollDelta = spacing
rule.setContent {
LazyStaggeredGrid(1, maxSize = containerSizeDp) {
items(list, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
list = listOf(0, 4, 2, 3, 1)
}
onAnimationFrame { fraction ->
if (fraction == 0f) {
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize),
fraction = fraction
)
rule.runOnUiThread {
runBlocking { state.scrollBy(itemSize * 2) }
}
assertPositions(
2 to AxisOffset(0f, 0f),
3 to AxisOffset(0f, itemSize),
// after the first scroll the new position of item 1 is still not reached
// so the target didn't change, we still aim to end right after the bounds
1 to AxisOffset(0f, itemSize),
fraction = fraction
)
rule.runOnUiThread {
runBlocking { state.scrollBy(scrollDelta) }
}
assertPositions(
2 to AxisOffset(0f, 0f - scrollDelta),
3 to AxisOffset(0f, itemSize - scrollDelta),
// after the second scroll the item 1 is visible, so we know its new target
// position. the animation is now targeting the real end position and now
// we are reacting on the scroll deltas
1 to AxisOffset(0f, itemSize - scrollDelta),
fraction = fraction
)
}
assertPositions(
2 to AxisOffset(0f, -scrollDelta),
3 to AxisOffset(0f, itemSize - scrollDelta),
1 to AxisOffset(0f, itemSize - scrollDelta + itemSize * fraction),
fraction = fraction
)
}
}
@Test
fun interruptedSizeChange() {
var item0Size by mutableStateOf(itemSizeDp)
val animSpec = spring(visibilityThreshold = IntOffset.VisibilityThreshold)
rule.setContent {
LazyStaggeredGrid(cells = 1) {
items(2, key = { it }) {
Item(it, if (it == 0) item0Size else itemSizeDp, animSpec = animSpec)
}
}
}
rule.runOnUiThread {
item0Size = itemSize2Dp
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
onAnimationFrame(duration = FrameDuration) { fraction ->
if (fraction == 0f) {
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, itemSize)
)
} else {
assertThat(fraction).isEqualTo(1f)
val valueAfterOneFrame =
animSpec.getValueAtFrame(1, from = itemSize, to = itemSize2)
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, valueAfterOneFrame)
)
}
}
rule.runOnUiThread {
item0Size = 0.dp
}
rule.waitForIdle()
val startValue = animSpec.getValueAtFrame(2, from = itemSize, to = itemSize2)
val startVelocity = animSpec.getVelocityAtFrame(2, from = itemSize, to = itemSize2)
onAnimationFrame(duration = FrameDuration) { fraction ->
if (fraction == 0f) {
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, startValue)
)
} else {
assertThat(fraction).isEqualTo(1f)
val valueAfterThreeFrames = animSpec.getValueAtFrame(
1,
from = startValue,
to = 0f,
initialVelocity = startVelocity
)
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(0f, valueAfterThreeFrames)
)
}
}
}
@Test
fun columnCountChange() {
var columnCount by mutableStateOf(2)
val containerCrossAxisSize = itemSizeDp * 2
rule.setContent {
LazyStaggeredGrid(
cells = columnCount,
maxSize = itemSizeDp,
crossAxisSize = containerCrossAxisSize
) {
items(10, key = { it }) {
Item(it)
}
}
}
rule.runOnUiThread {
columnCount = 1
}
onAnimationFrame { _ ->
// todo: proper animations when removal is supported
assertPositions(
0 to AxisOffset(0f, 0f),
1 to AxisOffset(itemSize, 0f)
)
}
}
private fun AxisOffset(crossAxis: Float, mainAxis: Float) =
if (isVertical) Offset(crossAxis, mainAxis) else Offset(mainAxis, crossAxis)
private val Offset.mainAxis: Float get() = if (isVertical) y else x
private fun assertPositions(
vararg expected: Pair<Any, Offset>,
crossAxis: List<Pair<Any, Float>>? = null,
fraction: Float? = null,
autoReverse: Boolean = reverseLayout
) {
val roundedExpected = expected.map { it.first to it.second.round() }
val actualBounds = rule.onAllNodes(NodesWithTagMatcher)
.fetchSemanticsNodes()
.associateBy(
keySelector = { it.config[SemanticsProperties.TestTag] },
valueTransform = { IntRect(it.positionInRoot.round(), it.size) }
)
val actualPositions = expected.map {
it.first to actualBounds.getValue(it.first.toString()).topLeft
}
val subject = if (fraction == null) {
assertThat(actualPositions)
} else {
Truth.assertWithMessage("Fraction=$fraction").that(actualPositions)
}
subject.isEqualTo(
roundedExpected.let { list ->
if (!autoReverse) {
list
} else {
val containerSize = actualBounds.getValue(ContainerTag).size
list.map {
val itemSize = actualBounds.getValue(it.first.toString()).size
it.first to
IntOffset(
if (isVertical) {
it.second.x
} else {
containerSize.width - itemSize.width - it.second.x
},
if (!isVertical) {
it.second.y
} else {
containerSize.height - itemSize.height - it.second.y
}
)
}
}
}
)
if (crossAxis != null) {
val actualCross = expected.map {
it.first to actualBounds.getValue(it.first.toString()).topLeft
.let { offset -> if (isVertical) offset.x else offset.y }
}
Truth.assertWithMessage(
"CrossAxis" + if (fraction != null) "for fraction=$fraction" else ""
)
.that(actualCross)
.isEqualTo(crossAxis.map { it.first to it.second.roundToInt() })
}
}
private fun assertLayoutInfoPositions(vararg offsets: Pair<Any, Offset>) {
rule.runOnIdle {
assertThat(visibleItemsOffsets).isEqualTo(offsets.map { it.first to it.second.round() })
}
}
private val visibleItemsOffsets: List<Pair<Any, IntOffset>>
get() = state.layoutInfo.visibleItemsInfo.map {
it.key to it.offset
}
private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
require(duration.mod(FrameDuration) == 0L)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
var expectedTime = rule.mainClock.currentTime
for (i in 0..duration step FrameDuration) {
val fraction = i / duration.toFloat()
onFrame(fraction)
if (i < duration) {
rule.mainClock.advanceTimeBy(FrameDuration)
expectedTime += FrameDuration
assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
}
}
}
@Composable
private fun LazyStaggeredGrid(
cells: Int,
minSize: Dp = 0.dp,
maxSize: Dp = containerSizeDp,
startIndex: Int = 0,
startPadding: Dp = 0.dp,
endPadding: Dp = 0.dp,
spacing: Dp = 0.dp,
crossAxisSize: Dp? = null,
content: LazyStaggeredGridScope.() -> Unit
) {
state = rememberLazyStaggeredGridState(startIndex)
if (isVertical) {
LazyVerticalStaggeredGrid(
StaggeredGridCells.Fixed(cells),
Modifier
.requiredHeightIn(minSize, maxSize)
.requiredWidth(crossAxisSize ?: (itemSizeDp * cells))
.testTag(ContainerTag),
state = state,
verticalItemSpacing = spacing,
reverseLayout = reverseLayout,
contentPadding = PaddingValues(top = startPadding, bottom = endPadding),
content = content
)
} else {
LazyHorizontalStaggeredGrid(
StaggeredGridCells.Fixed(cells),
Modifier
.requiredWidthIn(minSize, maxSize)
.requiredHeight(itemSizeDp * cells)
.testTag(ContainerTag),
state = state,
reverseLayout = reverseLayout,
horizontalItemSpacing = spacing,
contentPadding = PaddingValues(start = startPadding, end = endPadding),
content = content
)
}
}
@Composable
private fun LazyStaggeredGridItemScope.Item(
tag: Int,
size: Dp = itemSizeDp,
animSpec: FiniteAnimationSpec<IntOffset>? = AnimSpec
) {
Box(
if (animSpec != null) {
Modifier.animateItemPlacement(animSpec)
} else {
Modifier
}
.then(
if (isVertical) {
Modifier.requiredHeight(size)
} else {
Modifier.requiredWidth(size)
}
)
.testTag(tag.toString())
)
}
private fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(
expected: Dp
): SemanticsNodeInteraction {
return if (isVertical) assertHeightIsEqualTo(expected) else assertWidthIsEqualTo(expected)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() = arrayOf(
Config(isVertical = true, reverseLayout = false),
Config(isVertical = false, reverseLayout = false),
Config(isVertical = true, reverseLayout = true),
Config(isVertical = false, reverseLayout = true),
)
class Config(
val isVertical: Boolean,
val reverseLayout: Boolean
) {
override fun toString() =
(if (isVertical) "LazyVerticalGrid" else "LazyHorizontalGrid") +
(if (reverseLayout) "(reverse)" else "")
}
}
}
private val FrameDuration = 16L
private val Duration = 64L // 4 frames, so we get 0f, 0.25f, 0.5f, 0.75f and 1f fractions
private val AnimSpec = tween<IntOffset>(Duration.toInt(), easing = LinearEasing)
private val ContainerTag = "container"
private val NodesWithTagMatcher = SemanticsMatcher("NodesWithTag") {
it.config.contains(SemanticsProperties.TestTag)
}