Full span support for the staggered grid
Updates staggered grid to support items spanned to the full line.
Test: LazyStaggeredGrid tests
Relnote: "Added full line span support to LazyStaggeredGrid"
Change-Id: I28252ba175f719bb6f731341cfad476c98e9c5e8
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt
index 53a8c9a..705b8f5 100644
--- a/compose/foundation/foundation/api/public_plus_experimental_current.txt
+++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -902,10 +902,10 @@
public final class LazyStaggeredGridDslKt {
method @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void LazyHorizontalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope,kotlin.Unit> content);
method @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void LazyVerticalStaggeredGrid(androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.gestures.FlingBehavior flingBehavior, optional boolean userScrollEnabled, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope,kotlin.Unit> content);
- method @androidx.compose.foundation.ExperimentalFoundationApi public static <T> void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super T,kotlin.Unit> itemContent);
- method @androidx.compose.foundation.ExperimentalFoundationApi public static <T> void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super T,kotlin.Unit> itemContent);
- method @androidx.compose.foundation.ExperimentalFoundationApi public static <T> void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
- method @androidx.compose.foundation.ExperimentalFoundationApi public static <T> void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static inline <T> void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, optional kotlin.jvm.functions.Function1<? super T,androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan>? span, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super T,kotlin.Unit> itemContent);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static inline <T> void items(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, optional kotlin.jvm.functions.Function1<? super T,androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan>? span, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super T,kotlin.Unit> itemContent);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static inline <T> void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan>? span, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public static inline <T> void itemsIndexed(androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan>? span, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
}
@androidx.compose.foundation.ExperimentalFoundationApi public sealed interface LazyStaggeredGridItemInfo {
@@ -959,8 +959,8 @@
}
@androidx.compose.foundation.ExperimentalFoundationApi public sealed interface LazyStaggeredGridScope {
- method @androidx.compose.foundation.ExperimentalFoundationApi public void item(optional Object? key, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,kotlin.Unit> content);
- method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+ method @androidx.compose.foundation.ExperimentalFoundationApi public void item(optional Object? key, optional Object? contentType, optional androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan? span, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,kotlin.Unit> content);
+ method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan>? span, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
}
public final class LazyStaggeredGridSemanticsKt {
@@ -1010,6 +1010,17 @@
method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
}
+ @androidx.compose.foundation.ExperimentalFoundationApi public final class StaggeredGridItemSpan {
+ field public static final androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan.Companion Companion;
+ }
+
+ public static final class StaggeredGridItemSpan.Companion {
+ method public androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan getFullLine();
+ method public androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan getSingleLane();
+ property public final androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan FullLine;
+ property public final androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan SingleLane;
+ }
+
}
package androidx.compose.foundation.pager {
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
index 8972e35..7092564 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
@@ -59,6 +59,7 @@
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.samples.StickyHeaderSample
@@ -972,18 +973,26 @@
Button( if (count != 0) count-- }) { Text(text = "--") }
}
- val state = rememberLazyStaggeredGridState()
+ val state = rememberLazyStaggeredGridState(initialFirstVisibleItemIndex = 29)
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(3),
modifier = Modifier.fillMaxSize(),
state = state,
- contentPadding = PaddingValues(vertical = 500.dp, horizontal = 20.dp),
+ contentPadding = PaddingValues(vertical = 30.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
content = {
- items(count) {
- var expanded by rememberSaveable { mutableStateOf(false) }
+ items(
+ count,
+ span = {
+ if (it % 30 == 0)
+ StaggeredGridItemSpan.FullLine
+ else
+ StaggeredGridItemSpan.SingleLane
+ }
+ ) {
+ var expanded by remember { mutableStateOf(false) }
val index = indices.value[it % indices.value.size]
val color = colors[index]
Box(
@@ -1059,7 +1068,10 @@
Checkbox(checked = selected, >
selectedIndexes[item] = it
})
- Spacer(Modifier.width(16.dp).height(height))
+ Spacer(
+ Modifier
+ .width(16.dp)
+ .height(height))
Text("Item $item")
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt
index e1837d2..7a60556 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BaseLazyLayoutTestWithOrientation.kt
@@ -33,6 +33,8 @@
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.junit.Rule
@@ -119,6 +121,15 @@
assertTopPositionInRootIsEqualTo(expectedStart)
}
+ fun SemanticsNodeInteraction.assertAxisBounds(
+ offset: DpOffset,
+ size: DpSize
+ ) =
+ assertMainAxisStartPositionInRootIsEqualTo(offset.y)
+ .assertCrossAxisStartPositionInRootIsEqualTo(offset.x)
+ .assertMainAxisSizeIsEqualTo(size.height)
+ .assertCrossAxisSizeIsEqualTo(size.width)
+
fun PaddingValues(
mainAxis: Dp = 0.dp,
crossAxis: Dp = 0.dp
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridAnimatedScrollTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridAnimatedScrollTest.kt
index 7f7e5da..dbb0f0c 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridAnimatedScrollTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridAnimatedScrollTest.kt
@@ -19,20 +19,15 @@
import androidx.compose.animation.core.FloatSpringSpec
import androidx.compose.foundation.AutoTestFrameClock
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.grid.isEqualTo
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
@@ -139,7 +134,32 @@
state.animateScrollToItem(100)
}
rule.waitForIdle()
- assertThat(state.firstVisibleItemIndex).isEqualTo(90)
+ assertThat(state.firstVisibleItemIndex).isEqualTo(91)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+
+ @Test
+ fun animateScrollToItem_toFullSpan() {
+ runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+ state.animateScrollToItem(50, 10)
+ }
+ rule.waitForIdle()
+ assertThat(state.firstVisibleItemIndex).isEqualTo(50)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+ }
+
+ @Test
+ fun animateScrollToItem_toFullSpan_andBack() {
+ runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+ state.animateScrollToItem(50, 10)
+ }
+ rule.waitForIdle()
+
+ runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+ state.animateScrollToItem(45, 0)
+ }
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(44)
assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
}
@@ -232,13 +252,22 @@
state = state,
modifier = Modifier.axisSize(itemSizeDp * 2, itemSizeDp * 5)
) {
- items(100) {
+ items(
+ count = 100,
+ span = {
+ // mark a span to check scroll through
+ if (it == 50)
+ StaggeredGridItemSpan.FullLine
+ else
+ StaggeredGridItemSpan.SingleLane
+ }
+ ) {
BasicText(
"$it",
Modifier
.mainAxisSize(itemSizeDp)
.testTag("$it")
- .border(1.dp, Color.Black)
+ .debugBorder()
)
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
index c718b43..a17afd8 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
@@ -121,7 +121,7 @@
state = state
) {
items(100) {
- Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it"))
+ Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it").debugBorder())
}
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfoTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfoTest.kt
new file mode 100644
index 0000000..aac67a3
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfoTest.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.compose.foundation.lazy.staggeredgrid
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertEquals
+import org.junit.Test
+
+class LazyStaggeredGridLaneInfoTest {
+ private val laneInfo = LazyStaggeredGridLaneInfo()
+
+ @Test
+ fun emptySpan_unset() {
+ assertEquals(LazyStaggeredGridLaneInfo.Unset, laneInfo.getLane(0))
+ }
+
+ @Test
+ fun setLane() {
+ laneInfo.setLane(0, 42)
+ laneInfo.setLane(1, 0)
+
+ assertEquals(42, laneInfo.getLane(0))
+ assertEquals(0, laneInfo.getLane(1))
+ }
+
+ @Test
+ fun setLane_beyondBound() {
+ val bound = laneInfo.upperBound()
+ laneInfo.setLane(bound - 1, 42)
+ laneInfo.setLane(bound, 42)
+
+ assertEquals(42, laneInfo.getLane(bound - 1))
+ assertEquals(42, laneInfo.getLane(bound))
+ }
+
+ @Test
+ fun setLane_largeNumber() {
+ laneInfo.setLane(Int.MAX_VALUE / 2, 42)
+
+ assertEquals(42, laneInfo.getLane(Int.MAX_VALUE / 2))
+ }
+
+ @Test
+ fun setLane_decreaseBound() {
+ laneInfo.setLane(Int.MAX_VALUE / 2, 42)
+ laneInfo.setLane(0, 42)
+
+ assertEquals(-1, laneInfo.getLane(Int.MAX_VALUE / 2))
+ assertEquals(42, laneInfo.getLane(0))
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun setLane_negative() {
+ laneInfo.setLane(-1, 0)
+ }
+
+ @Test
+ fun setLaneGaps() {
+ laneInfo.setLane(0, 0)
+ laneInfo.setLane(1, 1)
+ laneInfo.setGaps(0, intArrayOf(42, 24))
+ laneInfo.setGaps(1, intArrayOf(12, 21))
+
+ assertThat(laneInfo.getGaps(0)).asList().isEqualTo(listOf(42, 24))
+ assertThat(laneInfo.getGaps(1)).asList().isEqualTo(listOf(12, 21))
+ }
+
+ @Test
+ fun missingLaneGaps() {
+ laneInfo.setLane(42, 0)
+ laneInfo.setGaps(0, intArrayOf(42, 24))
+
+ assertThat(laneInfo.getGaps(42)).isNull()
+ }
+
+ @Test
+ fun clearLaneGaps() {
+ laneInfo.setLane(42, 0)
+ laneInfo.setGaps(42, intArrayOf(42, 24))
+
+ assertThat(laneInfo.getGaps(42)).isNotNull()
+
+ laneInfo.setGaps(42, null)
+ assertThat(laneInfo.getGaps(42)).isNull()
+ }
+
+ @Test
+ fun resetOnLaneInfoContentMove() {
+ laneInfo.setLane(0, 0)
+ laneInfo.setGaps(0, intArrayOf(42, 24))
+
+ laneInfo.setLane(Int.MAX_VALUE / 2, 1)
+
+ laneInfo.setGaps(0, null)
+ assertThat(laneInfo.getGaps(0)).isNull()
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
index ffe5acc..224a1cc 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
@@ -33,7 +33,9 @@
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import androidx.test.filters.LargeTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
@@ -521,6 +523,133 @@
waitForPrefetch(9)
}
+ @Test
+ fun fullSpanIsPrefetchedCorrectly() {
+ val nodeConstraints = mutableMapOf<Int, Constraints>()
+ rule.setContent {
+ state = rememberLazyStaggeredGridState()
+ LazyStaggeredGrid(
+ 2,
+ Modifier.mainAxisSize(itemsSizeDp * 5f).crossAxisSize(itemsSizeDp * 2f),
+ state,
+ ) {
+ items(
+ count = 100,
+ span = {
+ if (it % 10 == 0)
+ StaggeredGridItemSpan.FullLine
+ else
+ StaggeredGridItemSpan.SingleLane
+ }
+ ) {
+ DisposableEffect(it) {
+ activeNodes.add(it)
+ onDispose {
+ activeNodes.remove(it)
+ activeMeasuredNodes.remove(it)
+ }
+ }
+ Spacer(
+ Modifier
+ .border(Dp.Hairline, Color.Black)
+ .testTag("$it")
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ activeMeasuredNodes.add(it)
+ nodeConstraints.put(it, constraints)
+ layout(placeable.width, placeable.height) {
+ placeable.place(0, 0)
+ }
+ }
+ .mainAxisSize(if (it == 0) itemsSizeDp else itemsSizeDp * 2)
+ )
+ }
+ }
+ }
+
+ // ┌─┬─┐
+ // │5├─┤
+ // ├─┤6│
+ // │7├─┤
+ // ├─┤8│
+ // └─┴─┘
+
+ state.scrollBy(itemsSizeDp * 5f)
+ assertThat(activeNodes).contains(9)
+
+ waitForPrefetch(10)
+ val expectedConstraints = if (vertical) {
+ Constraints.fixedWidth(itemsSizePx * 2)
+ } else {
+ Constraints.fixedHeight(itemsSizePx * 2)
+ }
+ assertThat(nodeConstraints[10]).isEqualTo(expectedConstraints)
+ }
+
+ @Test
+ fun fullSpanIsPrefetchedCorrectly_scrollingBack() {
+ rule.setContent {
+ state = rememberLazyStaggeredGridState()
+ LazyStaggeredGrid(
+ 2,
+ Modifier.mainAxisSize(itemsSizeDp * 5f),
+ state,
+ ) {
+ items(
+ count = 100,
+ span = {
+ if (it % 10 == 0)
+ StaggeredGridItemSpan.FullLine
+ else
+ StaggeredGridItemSpan.SingleLane
+ }
+ ) {
+ DisposableEffect(it) {
+ activeNodes.add(it)
+ onDispose {
+ activeNodes.remove(it)
+ activeMeasuredNodes.remove(it)
+ }
+ }
+ Spacer(
+ Modifier
+ .mainAxisSize(if (it == 0) itemsSizeDp else itemsSizeDp * 2)
+ .border(Dp.Hairline, Color.Black)
+ .testTag("$it")
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ activeMeasuredNodes.add(it)
+ layout(placeable.width, placeable.height) {
+ placeable.place(0, 0)
+ }
+ }
+ )
+ }
+ }
+ }
+
+ state.scrollBy(itemsSizeDp * 13f + 2.dp)
+ rule.waitForIdle()
+
+ // second scroll to make sure subcompose marks elements as /not/ active
+ state.scrollBy(2.dp)
+ rule.waitForIdle()
+
+ // ┌──┬──┐
+ // │11│12│
+ // ├──┼──┤
+ // │13│14│
+ // ├──┼──┤
+ // └──┴──┘
+
+ assertThat(activeNodes).contains(11)
+ assertThat(activeNodes).doesNotContain(10)
+
+ state.scrollBy(-1.dp)
+
+ waitForPrefetch(10)
+ }
+
private fun waitForPrefetch(index: Int) {
rule.waitUntil {
activeNodes.contains(index) && activeMeasuredNodes.contains(index)
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollTest.kt
index 49c2a4e..d52aa77 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollTest.kt
@@ -18,17 +18,14 @@
import androidx.compose.foundation.AutoTestFrameClock
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
@@ -204,6 +201,16 @@
assertThat(state.canScrollBackward).isTrue()
}
+ @Test
+ fun sctollToItem_fullSpan() = runBlocking {
+ withContext(Dispatchers.Main + AutoTestFrameClock()) {
+ state.scrollToItem(49, 10)
+ }
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(49)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+ }
+
@Composable
private fun TestContent() {
// |-|-|
@@ -220,13 +227,22 @@
state = state,
modifier = Modifier.axisSize(itemSizeDp * 2, itemSizeDp * 5)
) {
- items(100) {
+ items(
+ count = 100,
+ span = {
+ if (it == 50) {
+ StaggeredGridItemSpan.FullLine
+ } else {
+ StaggeredGridItemSpan.SingleLane
+ }
+ }
+ ) {
BasicText(
"$it",
Modifier
.mainAxisSize(itemSizeDp * ((it % 2) + 1))
.testTag("$it")
- .border(1.dp, Color.Black)
+ .debugBorder()
)
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSemanticTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSemanticTest.kt
index d9ba110..3ee1508 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSemanticTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSemanticTest.kt
@@ -19,6 +19,7 @@
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.text.BasicText
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
@@ -97,7 +98,7 @@
.crossAxisSize(itemSizeDp * 2)
) {
items(items = List(ItemCount) { it }, key = { key(it) }) {
- Spacer(Modifier.testTag(tag(it)).mainAxisSize(itemSizeDp))
+ BasicText("$it", Modifier.testTag(tag(it)).mainAxisSize(itemSizeDp))
}
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpansTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpansTest.kt
deleted file mode 100644
index 63621ea..0000000
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpansTest.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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.compose.foundation.lazy.staggeredgrid
-
-import kotlin.test.assertEquals
-import org.junit.Test
-
-class LazyStaggeredGridSpansTest {
- private val spans = LazyStaggeredGridSpans()
-
- @Test
- fun emptySpan_unset() {
- assertEquals(LazyStaggeredGridSpans.Unset, spans.getSpan(0))
- }
-
- @Test
- fun setSpan() {
- spans.setSpan(0, 42)
- spans.setSpan(1, 0)
-
- assertEquals(42, spans.getSpan(0))
- assertEquals(0, spans.getSpan(1))
- }
-
- @Test
- fun setSpan_beyondBound() {
- val bound = spans.upperBound()
- spans.setSpan(bound - 1, 42)
- spans.setSpan(bound, 42)
-
- assertEquals(42, spans.getSpan(bound - 1))
- assertEquals(42, spans.getSpan(bound))
- }
-
- @Test
- fun setSpan_largeNumber() {
- spans.setSpan(Int.MAX_VALUE / 2, 42)
-
- assertEquals(42, spans.getSpan(Int.MAX_VALUE / 2))
- }
-
- @Test
- fun setSpan_decreaseBound() {
- spans.setSpan(Int.MAX_VALUE / 2, 42)
- spans.setSpan(0, 42)
-
- assertEquals(-1, spans.getSpan(Int.MAX_VALUE / 2))
- assertEquals(42, spans.getSpan(0))
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun setSpan_negative() {
- spans.setSpan(-1, 0)
- }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
index 7eec8ab..01cadd0 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
@@ -35,10 +35,13 @@
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
@@ -851,7 +854,7 @@
fun resizingItems_maintainsScrollingRange() {
val state = LazyStaggeredGridState()
var itemSizes by mutableStateOf(
- List(20) {
+ List(10) {
itemSizeDp * (it % 4 + 1)
}
)
@@ -867,7 +870,7 @@
.testTag(LazyStaggeredGridTag)
.border(1.dp, Color.Red),
) {
- items(20) {
+ items(itemSizes.size) {
Box(
Modifier
.axisSize(
@@ -884,24 +887,24 @@
}
rule.onNodeWithTag(LazyStaggeredGridTag)
- .scrollMainAxisBy(itemSizeDp * 20)
+ .scrollMainAxisBy(itemSizeDp * 10)
- rule.onNodeWithTag("18")
- .assertMainAxisSizeIsEqualTo(itemSizes[18])
+ rule.onNodeWithTag("8")
+ .assertMainAxisSizeIsEqualTo(itemSizes[8])
- rule.onNodeWithTag("19")
- .assertMainAxisSizeIsEqualTo(itemSizes[19])
+ rule.onNodeWithTag("9")
+ .assertMainAxisSizeIsEqualTo(itemSizes[9])
itemSizes = itemSizes.reversed()
- rule.onNodeWithTag("18")
+ rule.onNodeWithTag("8")
.assertIsDisplayed()
- rule.onNodeWithTag("19")
+ rule.onNodeWithTag("9")
.assertIsDisplayed()
rule.onNodeWithTag(LazyStaggeredGridTag)
- .scrollMainAxisBy(-itemSizeDp * 20)
+ .scrollMainAxisBy(-itemSizeDp * 10)
rule.onNodeWithTag("0")
.assertMainAxisStartPositionInRootIsEqualTo(0.dp)
@@ -996,10 +999,16 @@
// check that scrolling back and forth doesn't crash
rule.onNodeWithTag(LazyStaggeredGridTag)
- .scrollMainAxisBy(10000.dp)
+ .scrollMainAxisBy(1000.dp)
rule.onNodeWithTag(LazyStaggeredGridTag)
- .scrollMainAxisBy(-10000.dp)
+ .scrollMainAxisBy(-1000.dp)
+
+ rule.onNodeWithTag("${Int.MAX_VALUE / 2}")
+ .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("${Int.MAX_VALUE / 2 + 1}")
+ .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
}
@Test
@@ -1294,4 +1303,394 @@
}
}
}
+
+ @Test
+ fun fullSpan_fillsAllCrossAxisSpace() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 10),
+ state
+ ) {
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("0").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+
+ rule.onNodeWithTag("0")
+ .assertMainAxisSizeIsEqualTo(itemSizeDp)
+ .assertCrossAxisSizeIsEqualTo(itemSizeDp * 3)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ }
+
+ @Test
+ fun fullSpan_leavesEmptyGapsWithOtherItems() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 10),
+ state
+ ) {
+ items(2) {
+ Box(Modifier.testTag("$it").mainAxisSize(itemSizeDp))
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+
+ // ┌─┬─┬─┐
+ // │0│1│#│
+ // ├─┴─┴─┤
+ // │full │
+ // └─────┘
+ rule.onNodeWithTag("0")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("1")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("full")
+ .assertAxisBounds(
+ DpOffset(0.dp, itemSizeDp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+ }
+
+ @Test
+ fun fullSpan_leavesGapsBetweenItems() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 10),
+ state
+ ) {
+ items(3) {
+ Box(Modifier.testTag("$it").mainAxisSize(itemSizeDp + itemSizeDp * it / 2))
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+
+ // ┌───┬───┬───┐
+ // │ 0 │ 1 │ 2 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤
+ // │ full │
+ // └───────────┘
+ rule.onNodeWithTag("0")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("1")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 1.5f)
+ )
+
+ rule.onNodeWithTag("2")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp * 2, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 2f)
+ )
+
+ rule.onNodeWithTag("full")
+ .assertAxisBounds(
+ DpOffset(0.dp, itemSizeDp * 2f),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+ }
+
+ @Test
+ fun fullSpan_scrollsCorrectly() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 2),
+ state
+ ) {
+ items(3) {
+ Box(
+ Modifier
+ .testTag("$it")
+ .mainAxisSize(itemSizeDp + itemSizeDp * it / 2)
+ )
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full").mainAxisSize(itemSizeDp))
+ }
+
+ items(3) {
+ Box(
+ Modifier
+ .testTag("${it + 3}")
+ .mainAxisSize(itemSizeDp + itemSizeDp * it / 2)
+ )
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full-2").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+
+ // ┌───┬───┬───┐
+ // │ 0 │ 1 │ 2 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤ <-- scroll offset
+ // │ full │
+ // ├───┬───┬───┤
+ // │ 3 │ 4 │ 5 │
+ // ├───┤ │ │ <-- end of screen
+ // │ └───┤ │
+ // ├───────┴───┤
+ // │ full-2 │
+ // └───────────┘
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(itemSizeDp * 2f)
+
+ rule.onNodeWithTag("full")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+
+ @Test
+ fun fullSpan_scrollsCorrectly_pastFullSpan() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 2),
+ state
+ ) {
+ repeat(10) { repeatIndex ->
+ items(3) {
+ Box(
+ Modifier
+ .testTag("${repeatIndex * 3 + it}")
+ .mainAxisSize(itemSizeDp + itemSizeDp * it / 2)
+ )
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full-$repeatIndex").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+ }
+
+ // ┌───┬───┬───┐
+ // │ 0 │ 1 │ 2 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤
+ // │ full-0 │
+ // ├───┬───┬───┤ <-- scroll offset
+ // │ 3 │ 4 │ 5 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤ <-- end of screen
+ // │ full-1 │
+ // └───────────┘
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(itemSizeDp * 3f)
+
+ rule.onNodeWithTag("3")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("4")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 1.5f)
+ )
+
+ rule.onNodeWithTag("5")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp * 2, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 2)
+ )
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(4)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+
+ @Test
+ fun fullSpan_scrollsCorrectly_pastFullSpan_andBack() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 2),
+ state
+ ) {
+ repeat(10) { repeatIndex ->
+ items(3) {
+ Box(
+ Modifier
+ .testTag("${repeatIndex * 3 + it}")
+ .mainAxisSize(itemSizeDp + itemSizeDp * it / 2)
+ )
+ }
+
+ item(span = StaggeredGridItemSpan.FullLine) {
+ Box(Modifier.testTag("full-$repeatIndex").mainAxisSize(itemSizeDp))
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(itemSizeDp * 3f)
+
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(-itemSizeDp * 3f)
+
+ // ┌───┬───┬───┐ <-- scroll offset
+ // │ 0 │ 1 │ 2 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤ <-- end of screen
+ // │ full-0 │
+ // ├───┬───┬───┤
+ // │ 3 │ 4 │ 5 │
+ // ├───┤ │ │
+ // │ └───┤ │
+ // ├───────┴───┤
+ // │ full-1 │
+ // └───────────┘
+
+ rule.onNodeWithTag("0")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("1")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 1.5f)
+ )
+
+ rule.onNodeWithTag("2")
+ .assertAxisBounds(
+ DpOffset(itemSizeDp * 2, 0.dp),
+ DpSize(itemSizeDp, itemSizeDp * 2)
+ )
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+
+ @Test
+ fun fullSpan_scrollsCorrectly_multipleFullSpans() {
+ val state = LazyStaggeredGridState()
+ state.prefetchingEnabled = false
+ rule.setContentWithTestViewConfiguration {
+ LazyStaggeredGrid(
+ 3,
+ Modifier
+ .testTag(LazyStaggeredGridTag)
+ .crossAxisSize(itemSizeDp * 3)
+ .mainAxisSize(itemSizeDp * 2),
+ state
+ ) {
+ items(10, span = { StaggeredGridItemSpan.FullLine }) {
+ Box(
+ Modifier
+ .testTag("$it")
+ .mainAxisSize(itemSizeDp)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(itemSizeDp * 3f)
+
+ rule.onNodeWithTag("3")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("4")
+ .assertAxisBounds(
+ DpOffset(0.dp, itemSizeDp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+
+ rule.onNodeWithTag(LazyStaggeredGridTag)
+ .scrollMainAxisBy(itemSizeDp * 10f)
+
+ rule.onNodeWithTag("8")
+ .assertAxisBounds(
+ DpOffset(0.dp, 0.dp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+
+ rule.onNodeWithTag("9")
+ .assertAxisBounds(
+ DpOffset(0.dp, itemSizeDp),
+ DpSize(itemSizeDp * 3, itemSizeDp)
+ )
+
+ assertThat(state.firstVisibleItemIndex).isEqualTo(8)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt
index 28f690f..1a506df 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridDsl.kt
@@ -214,7 +214,7 @@
/**
* Add a single item to the staggered grid.
*
- * @param key a factory of stable and unique keys representing the item. The key
+ * @param key a stable and unique key representing the item. The key
* MUST be saveable via Bundle on Android. If set to null (by default), the position of the
* item will be used as a key instead.
* Using the same key for multiple items in the staggered grid is not allowed.
@@ -222,15 +222,18 @@
* When you specify the key the scroll position will be maintained based on the key, which
* means if you add/remove items before the current visible item the item with the given key
* will be kept as the first visible one.
- * @param contentType a factory of content types representing the item. Content for item of
+ * @param contentType a content type representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a custom span for this item. Spans configure how many lanes defined by
+ * [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param content composable content displayed by current item
*/
@ExperimentalFoundationApi
fun item(
key: Any? = null,
contentType: Any? = null,
+ span: StaggeredGridItemSpan? = null,
content: @Composable LazyStaggeredGridItemScope.() -> Unit
)
@@ -249,12 +252,15 @@
* @param contentType a factory of content types representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a factory of custom spans for this item. Spans configure how many lanes defined
+ * by [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param itemContent composable content displayed by item on provided position
*/
fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null },
+ span: ((index: Int) -> StaggeredGridItemSpan)? = null,
itemContent: @Composable LazyStaggeredGridItemScope.(index: Int) -> Unit
)
}
@@ -274,14 +280,17 @@
* @param contentType a factory of content types representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a factory of custom spans for this item. Spans configure how many lanes defined
+ * by [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param itemContent composable content displayed by the provided item
*/
@ExperimentalFoundationApi
-fun <T> LazyStaggeredGridScope.items(
+inline fun <T> LazyStaggeredGridScope.items(
items: List<T>,
- key: ((item: T) -> Any)? = null,
- contentType: (item: T) -> Any? = { null },
- itemContent: @Composable LazyStaggeredGridItemScope.(item: T) -> Unit
+ noinline key: ((item: T) -> Any)? = null,
+ crossinline contentType: (item: T) -> Any? = { null },
+ noinline span: ((item: T) -> StaggeredGridItemSpan)? = null,
+ crossinline itemContent: @Composable LazyStaggeredGridItemScope.(item: T) -> Unit
) {
items(
count = items.size,
@@ -289,6 +298,9 @@
{ index -> key(items[index]) }
},
contentType = { index -> contentType(items[index]) },
+ span = span?.let {
+ { index -> span(items[index]) }
+ },
itemContent = { index -> itemContent(items[index]) }
)
}
@@ -308,14 +320,17 @@
* @param contentType a factory of content types representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a factory of custom spans for this item. Spans configure how many lanes defined
+ * by [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param itemContent composable content displayed given item and index
*/
@ExperimentalFoundationApi
-fun <T> LazyStaggeredGridScope.itemsIndexed(
+inline fun <T> LazyStaggeredGridScope.itemsIndexed(
items: List<T>,
- key: ((index: Int, item: T) -> Any)? = null,
- contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
- itemContent: @Composable LazyStaggeredGridItemScope.(index: Int, item: T) -> Unit
+ noinline key: ((index: Int, item: T) -> Any)? = null,
+ crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+ noinline span: ((index: Int, item: T) -> StaggeredGridItemSpan)? = null,
+ crossinline itemContent: @Composable LazyStaggeredGridItemScope.(index: Int, item: T) -> Unit
) {
items(
count = items.size,
@@ -323,6 +338,9 @@
{ index -> key(index, items[index]) }
},
contentType = { index -> contentType(index, items[index]) },
+ span = span?.let {
+ { index -> span(index, items[index]) }
+ },
itemContent = { index -> itemContent(index, items[index]) }
)
}
@@ -342,14 +360,17 @@
* @param contentType a factory of content types representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a factory of custom spans for this item. Spans configure how many lanes defined
+ * by [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param itemContent composable content displayed by the provided item
*/
@ExperimentalFoundationApi
-fun <T> LazyStaggeredGridScope.items(
+inline fun <T> LazyStaggeredGridScope.items(
items: Array<T>,
- key: ((item: T) -> Any)? = null,
- contentType: (item: T) -> Any? = { null },
- itemContent: @Composable LazyStaggeredGridItemScope.(item: T) -> Unit
+ noinline key: ((item: T) -> Any)? = null,
+ crossinline contentType: (item: T) -> Any? = { null },
+ noinline span: ((item: T) -> StaggeredGridItemSpan)? = null,
+ crossinline itemContent: @Composable LazyStaggeredGridItemScope.(item: T) -> Unit
) {
items(
count = items.size,
@@ -357,6 +378,9 @@
{ index -> key(items[index]) }
},
contentType = { index -> contentType(items[index]) },
+ span = span?.let {
+ { index -> span(items[index]) }
+ },
itemContent = { index -> itemContent(items[index]) }
)
}
@@ -376,14 +400,17 @@
* @param contentType a factory of content types representing the item. Content for item of
* the same type can be reused more efficiently. null is a valid type as well and items
* of such type will be considered compatible.
+ * @param span a factory of custom spans for this item. Spans configure how many lanes defined
+ * by [StaggeredGridCells] the item will occupy. By default each item will take one lane.
* @param itemContent composable content displayed given item and index
*/
@ExperimentalFoundationApi
-fun <T> LazyStaggeredGridScope.itemsIndexed(
+inline fun <T> LazyStaggeredGridScope.itemsIndexed(
items: Array<T>,
- key: ((index: Int, item: T) -> Any)? = null,
- contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
- itemContent: @Composable LazyStaggeredGridItemScope.(index: Int, item: T) -> Unit
+ noinline key: ((index: Int, item: T) -> Any)? = null,
+ crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+ noinline span: ((index: Int, item: T) -> StaggeredGridItemSpan)? = null,
+ crossinline itemContent: @Composable LazyStaggeredGridItemScope.(index: Int, item: T) -> Unit
) {
items(
count = items.size,
@@ -391,6 +418,9 @@
{ index -> key(index, items[index]) }
},
contentType = { index -> contentType(index, items[index]) },
+ span = span?.let {
+ { index -> span(index, items[index]) }
+ },
itemContent = { index -> itemContent(index, items[index]) }
)
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt
index 5d35898..be07e927 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt
@@ -25,12 +25,17 @@
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+@OptIn(ExperimentalFoundationApi::class)
+internal interface LazyStaggeredGridItemProvider : LazyLayoutItemProvider {
+ val spanProvider: LazyStaggeredGridSpanProvider
+}
+
@Composable
@ExperimentalFoundationApi
internal fun rememberStaggeredGridItemProvider(
state: LazyStaggeredGridState,
content: LazyStaggeredGridScope.() -> Unit,
-): LazyLayoutItemProvider {
+): LazyStaggeredGridItemProvider {
val latestContent = rememberUpdatedState(content)
val nearestItemsRangeState = rememberLazyNearestItemsRangeState(
firstVisibleItemIndex = { state.firstVisibleItemIndex },
@@ -40,16 +45,24 @@
return remember(state) {
val itemProviderState = derivedStateOf {
val scope = LazyStaggeredGridScopeImpl().apply(latestContent.value)
- LazyLayoutItemProvider(
+ object : LazyLayoutItemProvider by LazyLayoutItemProvider(
scope.intervals,
nearestItemsRangeState.value,
- ) { interval, index ->
- interval.value.item.invoke(
- LazyStaggeredGridItemScopeImpl,
- index - interval.startIndex
- )
+ { interval, index ->
+ interval.value.item.invoke(
+ LazyStaggeredGridItemScopeImpl,
+ index - interval.startIndex
+ )
+ }
+ ), LazyStaggeredGridItemProvider {
+ override val spanProvider = LazyStaggeredGridSpanProvider(scope.intervals)
}
}
- object : LazyLayoutItemProvider by DelegatingLazyLayoutItemProvider(itemProviderState) { }
+
+ object : LazyLayoutItemProvider by DelegatingLazyLayoutItemProvider(itemProviderState),
+ LazyStaggeredGridItemProvider {
+
+ override val spanProvider get() = itemProviderState.value.spanProvider
+ }
}
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfo.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfo.kt
new file mode 100644
index 0000000..eee96e9
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridLaneInfo.kt
@@ -0,0 +1,207 @@
+/*
+ * 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.compose.foundation.lazy.staggeredgrid
+
+/**
+ * Utility class to remember grid lane assignments in a sliding window relative to requested
+ * item position (usually reflected by scroll position).
+ * Remembers the maximum range of remembered items is reflected by [MaxCapacity], if index is beyond
+ * the bounds, [anchor] moves to reflect new position.
+ */
+internal class LazyStaggeredGridLaneInfo {
+ private var anchor = 0
+ private var lanes = IntArray(16)
+ private val spannedItems = ArrayDeque<SpannedItem>()
+
+ private class SpannedItem(val index: Int, var gaps: IntArray)
+
+ /**
+ * Sets given lane for given item index.
+ */
+ fun setLane(itemIndex: Int, lane: Int) {
+ require(itemIndex >= 0) { "Negative lanes are not supported" }
+ ensureValidIndex(itemIndex)
+ lanes[itemIndex - anchor] = lane + 1
+ }
+
+ /**
+ * Get lane for given item index.
+ * @return lane previously recorded for given item or [Unset] if it doesn't exist.
+ */
+ fun getLane(itemIndex: Int): Int {
+ if (itemIndex < lowerBound() || itemIndex >= upperBound()) {
+ return Unset
+ }
+ return lanes[itemIndex - anchor] - 1
+ }
+
+ /**
+ * Checks whether item can be in the target lane
+ * @param itemIndex item to check lane for
+ * @param targetLane lane it should belong to
+ */
+ fun assignedToLane(itemIndex: Int, targetLane: Int): Boolean {
+ val lane = getLane(itemIndex)
+ return lane == targetLane || lane == Unset || lane == FullSpan
+ }
+
+ /**
+ * @return upper bound of currently valid item range
+ */
+ /* @VisibleForTests */
+ fun upperBound(): Int = anchor + lanes.size
+
+ /**
+ * @return lower bound of currently valid item range
+ */
+ /* @VisibleForTests */
+ fun lowerBound(): Int = anchor
+
+ /**
+ * Delete remembered lane assignments.
+ */
+ fun reset() {
+ lanes.fill(0)
+ spannedItems.clear()
+ }
+
+ /**
+ * Find the previous item relative to [itemIndex] set to target lane
+ * @return found item index or -1 if it doesn't exist.
+ */
+ fun findPreviousItemIndex(itemIndex: Int, targetLane: Int): Int {
+ for (i in (itemIndex - 1) downTo 0) {
+ if (assignedToLane(i, targetLane)) {
+ return i
+ }
+ }
+ return -1
+ }
+
+ /**
+ * Find the next item relative to [itemIndex] set to target lane
+ * @return found item index or [upperBound] if it doesn't exist.
+ */
+ fun findNextItemIndex(itemIndex: Int, targetLane: Int): Int {
+ for (i in itemIndex + 1 until upperBound()) {
+ if (assignedToLane(i, targetLane)) {
+ return i
+ }
+ }
+ return upperBound()
+ }
+
+ fun ensureValidIndex(requestedIndex: Int) {
+ val requestedCapacity = requestedIndex - anchor
+
+ if (requestedCapacity in 0 until MaxCapacity) {
+ // simplest path - just grow array to given capacity
+ ensureCapacity(requestedCapacity + 1)
+ } else {
+ // requested index is beyond current span bounds
+ // rebase anchor so that requested index is in the middle of span array
+ val oldAnchor = anchor
+ anchor = maxOf(requestedIndex - (lanes.size / 2), 0)
+ var delta = anchor - oldAnchor
+
+ if (delta >= 0) {
+ // copy previous span data if delta is smaller than span size
+ if (delta < lanes.size) {
+ lanes.copyInto(
+ lanes,
+ destinationOffset = 0,
+ startIndex = delta,
+ endIndex = lanes.size
+ )
+ }
+ // fill the rest of the spans with default values
+ lanes.fill(0, maxOf(0, lanes.size - delta), lanes.size)
+ } else {
+ delta = -delta
+ // check if we can grow spans to match delta
+ if (lanes.size + delta < MaxCapacity) {
+ // grow spans and leave space in the start
+ ensureCapacity(lanes.size + delta + 1, delta)
+ } else {
+ // otherwise, just move data that fits
+ if (delta < lanes.size) {
+ lanes.copyInto(
+ lanes,
+ destinationOffset = delta,
+ startIndex = 0,
+ endIndex = lanes.size - delta
+ )
+ }
+ // fill the rest of the spans with default values
+ lanes.fill(0, 0, minOf(lanes.size, delta))
+ }
+ }
+ }
+
+ // ensure full item spans beyond saved index are forgotten to save memory
+
+ while (spannedItems.isNotEmpty() && spannedItems.first().index < lowerBound()) {
+ spannedItems.removeFirst()
+ }
+
+ while (spannedItems.isNotEmpty() && spannedItems.last().index > upperBound()) {
+ spannedItems.removeLast()
+ }
+ }
+
+ fun setGaps(itemIndex: Int, gaps: IntArray?) {
+ val foundIndex = spannedItems.binarySearchBy(itemIndex) { it.index }
+ if (foundIndex < 0) {
+ if (gaps == null) {
+ return
+ }
+ // not found, insert new element
+ val insertionIndex = -(foundIndex + 1)
+ spannedItems.add(insertionIndex, SpannedItem(itemIndex, gaps))
+ } else {
+ if (gaps == null) {
+ // found, but gaps are reset, remove item
+ spannedItems.removeAt(foundIndex)
+ } else {
+ // found, update gaps
+ spannedItems[foundIndex].gaps = gaps
+ }
+ }
+ }
+
+ fun getGaps(itemIndex: Int): IntArray? {
+ val foundIndex = spannedItems.binarySearchBy(itemIndex) { it.index }
+ return spannedItems.getOrNull(foundIndex)?.gaps
+ }
+
+ private fun ensureCapacity(capacity: Int, newOffset: Int = 0) {
+ require(capacity <= MaxCapacity) {
+ "Requested item capacity $capacity is larger than max supported: $MaxCapacity!"
+ }
+ if (lanes.size < capacity) {
+ var newSize = lanes.size
+ while (newSize < capacity) newSize *= 2
+ lanes = lanes.copyInto(IntArray(newSize), destinationOffset = newOffset)
+ }
+ }
+
+ companion object {
+ private const val MaxCapacity = 131_072 // Closest to 100_000, 2 ^ 17
+ internal const val Unset = -1
+ internal const val FullSpan = -2
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
index 138d229..1c365bf 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
@@ -21,6 +21,8 @@
import androidx.compose.foundation.fastMaxOfOrNull
import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridLaneInfo.Companion.FullSpan
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridLaneInfo.Companion.Unset
import androidx.compose.runtime.collection.MutableVector
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.layout.Placeable
@@ -30,15 +32,24 @@
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.packInts
+import androidx.compose.ui.util.unpackInt1
+import androidx.compose.ui.util.unpackInt2
import kotlin.math.abs
-import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
+private const val DebugLoggingEnabled = false
+private inline fun debugLog(message: () -> String) {
+ if (DebugLoggingEnabled) {
+ println(message())
+ }
+}
+
@ExperimentalFoundationApi
internal fun LazyLayoutMeasureScope.measureStaggeredGrid(
state: LazyStaggeredGridState,
- itemProvider: LazyLayoutItemProvider,
+ itemProvider: LazyStaggeredGridItemProvider,
resolvedSlotSums: IntArray,
constraints: Constraints,
isVertical: Boolean,
@@ -77,23 +88,23 @@
} else {
// Grid got resized (or we are in a initial state)
// Adjust indices accordingly
- context.spans.reset()
+ context.laneInfo.reset()
IntArray(resolvedSlotSums.size).apply {
// Try to adjust indices in case grid got resized
for (lane in indices) {
this[lane] = if (
- lane < firstVisibleIndices.size && firstVisibleIndices[lane] != -1
+ lane < firstVisibleIndices.size && firstVisibleIndices[lane] != Unset
) {
firstVisibleIndices[lane]
} else {
if (lane == 0) {
0
} else {
- context.findNextItemIndex(this[lane - 1], lane)
+ maxInRange(SpanRange(0, lane)) + 1
}
}
// Ensure spans are updated to be in correct range
- context.spans.setSpan(this[lane], lane)
+ context.laneInfo.setLane(this[lane], lane)
}
}
}
@@ -127,7 +138,7 @@
@OptIn(ExperimentalFoundationApi::class)
private class LazyStaggeredGridMeasureContext(
val state: LazyStaggeredGridState,
- val itemProvider: LazyLayoutItemProvider,
+ val itemProvider: LazyStaggeredGridItemProvider,
val resolvedSlotSums: IntArray,
val constraints: Constraints,
val isVertical: Boolean,
@@ -143,20 +154,40 @@
isVertical,
itemProvider,
measureScope,
- resolvedSlotSums
- ) { index, lane, key, placeables ->
- val isLastInLane = spans.findNextItemIndex(index, lane) >= itemProvider.itemCount
+ resolvedSlotSums,
+ crossAxisSpacing
+ ) { index, lane, span, key, placeables ->
LazyStaggeredGridMeasuredItem(
index,
key,
placeables,
isVertical,
contentOffset,
- if (isLastInLane) 0 else mainAxisSpacing
+ mainAxisSpacing,
+ lane,
+ span
)
}
- val spans = state.spans
+ val laneInfo = state.laneInfo
+
+ val laneCount = resolvedSlotSums.size
+
+ fun LazyStaggeredGridItemProvider.isFullSpan(itemIndex: Int): Boolean =
+ spanProvider.isFullSpan(itemIndex)
+
+ fun LazyStaggeredGridItemProvider.getSpanRange(itemIndex: Int, lane: Int): SpanRange {
+ val isFullSpan = spanProvider.isFullSpan(itemIndex)
+ val span = if (isFullSpan) laneCount else 1
+ val targetLane = if (isFullSpan) 0 else lane
+ return SpanRange(targetLane, span)
+ }
+
+ inline val SpanRange.isFullSpan: Boolean
+ get() = end - start != 1
+
+ inline val SpanRange.laneInfo: Int
+ get() = if (isFullSpan) FullSpan else start
}
@ExperimentalFoundationApi
@@ -169,7 +200,7 @@
with(measureScope) {
val itemCount = itemProvider.itemCount
- if (itemCount <= 0 || resolvedSlotSums.isEmpty()) {
+ if (itemCount <= 0 || laneCount == 0) {
return LazyStaggeredGridMeasureResult(
firstVisibleItemIndices = initialItemIndices,
firstVisibleItemScrollOffsets = initialItemOffsets,
@@ -202,7 +233,7 @@
firstItemOffsets.offsetBy(-scrollDelta)
// this will contain all the MeasuredItems representing the visible items
- val measuredItems = Array(resolvedSlotSums.size) {
+ val measuredItems = Array(laneCount) {
ArrayDeque<LazyStaggeredGridMeasuredItem>()
}
@@ -215,7 +246,7 @@
val itemIndex = firstItemIndices[lane]
val itemOffset = firstItemOffsets[lane]
- if (itemOffset < -mainAxisSpacing && itemIndex > 0) {
+ if (itemOffset < maxOf(-mainAxisSpacing, 0) && itemIndex > 0) {
return true
}
}
@@ -225,33 +256,60 @@
var laneToCheckForGaps = -1
- // we had scrolled backward or we compose items in the start padding area, which means
- // items before current firstItemScrollOffset should be visible. compose them and update
- // firstItemScrollOffset
- while (hasSpaceBeforeFirst()) {
- val laneIndex = firstItemOffsets.indexOfMinValue()
- val previousItemIndex = findPreviousItemIndex(
- item = firstItemIndices[laneIndex],
- lane = laneIndex
- )
+ debugLog { "=========== MEASURE START ==========" }
+ debugLog {
+ "| Filling up from indices: ${firstItemIndices.toList()}, " +
+ "offsets: ${firstItemOffsets.toList()}"
+ }
+ // we had scrolled backward or we compose items in the start padding area, which means
+ // items before current firstItemOffset should be visible. compose them and update
+ // firstItemOffsets
+ while (hasSpaceBeforeFirst()) {
+ // staggered grid always keeps item index increasing top to bottom
+ // the first item that should contain something before it must have the largest index
+ // among the rest
+ val laneIndex = firstItemIndices.indexOfMaxValue()
+ val itemIndex = firstItemIndices[laneIndex]
+
+ // other lanes might have smaller offsets than the one chosen above, which indicates
+ // incorrect measurement (e.g. item was deleted or it changed size)
+ // correct this by offsetting affected lane back to match currently chosen offset
+ for (i in firstItemOffsets.indices) {
+ if (
+ firstItemIndices[i] != firstItemIndices[laneIndex] &&
+ firstItemOffsets[i] < firstItemOffsets[laneIndex]
+ ) {
+ // If offset of the lane is smaller than currently chosen lane,
+ // offset the lane to be where current value of the chosen index is.
+ firstItemOffsets[i] = firstItemOffsets[laneIndex]
+ }
+ }
+
+ val previousItemIndex = findPreviousItemIndex(itemIndex, laneIndex)
if (previousItemIndex < 0) {
laneToCheckForGaps = laneIndex
break
}
- if (spans.getSpan(previousItemIndex) == LazyStaggeredGridSpans.Unset) {
- spans.setSpan(previousItemIndex, laneIndex)
- }
-
+ val spanRange = itemProvider.getSpanRange(previousItemIndex, laneIndex)
+ laneInfo.setLane(previousItemIndex, spanRange.laneInfo)
val measuredItem = measuredItemProvider.getAndMeasure(
- previousItemIndex,
- laneIndex
+ index = previousItemIndex,
+ span = spanRange
)
- measuredItems[laneIndex].addFirst(measuredItem)
- firstItemIndices[laneIndex] = previousItemIndex
- firstItemOffsets[laneIndex] += measuredItem.sizeWithSpacings
+ val offset = firstItemOffsets.maxInRange(spanRange)
+ val gaps = if (spanRange.isFullSpan) laneInfo.getGaps(previousItemIndex) else null
+ spanRange.forEach { lane ->
+ firstItemIndices[lane] = previousItemIndex
+ val gap = if (gaps == null) 0 else gaps[lane]
+ firstItemOffsets[lane] = offset + measuredItem.sizeWithSpacings + gap
+ }
+ }
+ debugLog {
+ @Suppress("ListIterator")
+ "| up filled, measured items are ${measuredItems.map { it.map { it.index } }}"
}
fun misalignedStart(referenceLane: Int): Boolean {
@@ -261,17 +319,19 @@
// Case 1: Each lane has laid out all items, but offsets do no match
val misalignedOffsets = laneRange.any { lane ->
- findPreviousItemIndex(firstItemIndices[lane], lane) == -1 &&
+ findPreviousItemIndex(firstItemIndices[lane], lane) == Unset &&
firstItemOffsets[lane] != firstItemOffsets[referenceLane]
}
// Case 2: Some lanes are still missing items, and there's no space left to place them
val moreItemsInOtherLanes = laneRange.any { lane ->
- findPreviousItemIndex(firstItemIndices[lane], lane) != -1 &&
+ findPreviousItemIndex(firstItemIndices[lane], lane) != Unset &&
firstItemOffsets[lane] >= firstItemOffsets[referenceLane]
}
// Case 3: the first item is in the wrong lane (it should always be in
// the first one)
- val firstItemInWrongLane = spans.getSpan(0) != 0
+ val firstItemLane = laneInfo.getLane(0)
+ val firstItemInWrongLane =
+ firstItemLane != 0 && firstItemLane != Unset && firstItemLane != FullSpan
// If items are not aligned, reset all measurement data we gathered before and
// proceed with initial measure
return misalignedOffsets || moreItemsInOtherLanes || firstItemInWrongLane
@@ -285,6 +345,9 @@
if (firstItemOffsets[0] < minOffset) {
scrollDelta += firstItemOffsets[0]
firstItemOffsets.offsetBy(minOffset - firstItemOffsets[0])
+ debugLog {
+ "| correcting scroll delta from ${firstItemOffsets[0]} to $minOffset"
+ }
}
// neutralize previously added start padding as we stopped filling the before content padding
@@ -297,7 +360,7 @@
if (laneToCheckForGaps != -1) {
val lane = laneToCheckForGaps
if (misalignedStart(lane) && canRestartMeasure) {
- spans.reset()
+ laneInfo.reset()
return measure(
initialScrollDelta = scrollDelta,
initialItemIndices = IntArray(firstItemIndices.size) { -1 },
@@ -309,29 +372,73 @@
}
}
- val currentItemIndices = initialItemIndices.copyOf().apply {
- // ensure indices match item count, in case it decreased
- ensureIndicesInRange(this, itemCount)
- }
- val currentItemOffsets = IntArray(initialItemOffsets.size) {
- -(initialItemOffsets[it] - scrollDelta)
+ // start measuring down from first item indices/offsets decided above to ensure correct
+ // arrangement.
+ // this means we are calling measure second time on items previously measured in this
+ // function, but LazyLayout caches them, so no overhead.
+ val currentItemIndices = firstItemIndices.copyOf()
+ val currentItemOffsets = IntArray(firstItemOffsets.size) {
+ -firstItemOffsets[it]
}
val maxOffset = (mainAxisAvailableSize + afterContentPadding).coerceAtLeast(0)
- // compose first visible items we received from state
- currentItemIndices.forEachIndexed { laneIndex, itemIndex ->
- if (itemIndex < 0) return@forEachIndexed
+ debugLog {
+ "| filling from current: indices: ${currentItemIndices.toList()}, " +
+ "offsets: ${currentItemOffsets.toList()}"
+ }
- val measuredItem = measuredItemProvider.getAndMeasure(itemIndex, laneIndex)
- currentItemOffsets[laneIndex] += measuredItem.sizeWithSpacings
- measuredItems[laneIndex].addLast(measuredItem)
+ // current item should be pointing to the index of previously measured item below,
+ // as lane assignments must be decided based on size and offset of all previous items
+ // this loop makes sure to measure items that were initially passed to the current item
+ // indices with correct item order
+ var initialItemsMeasured = 0
+ var initialLaneToMeasure = currentItemIndices.indexOfMinValue()
+ while (initialLaneToMeasure != -1 && initialItemsMeasured < laneCount) {
+ val itemIndex = currentItemIndices[initialLaneToMeasure]
+ val laneIndex = initialLaneToMeasure
- spans.setSpan(itemIndex, laneIndex)
+ initialLaneToMeasure = currentItemIndices.indexOfMinValue(minBound = itemIndex)
+ initialItemsMeasured++
+
+ if (itemIndex < 0) continue
+
+ val spanRange = itemProvider.getSpanRange(itemIndex, laneIndex)
+ val measuredItem = measuredItemProvider.getAndMeasure(
+ itemIndex,
+ spanRange
+ )
+
+ laneInfo.setLane(itemIndex, spanRange.laneInfo)
+ val offset = currentItemOffsets.maxInRange(spanRange) + measuredItem.sizeWithSpacings
+ spanRange.forEach { lane ->
+ currentItemOffsets[lane] = offset
+ currentItemIndices[lane] = itemIndex
+ measuredItems[lane].addLast(measuredItem)
+ }
+
+ if (currentItemOffsets[spanRange.start] <= minOffset + mainAxisSpacing) {
+ measuredItem.isVisible = false
+ }
+
+ if (spanRange.isFullSpan) {
+ // full span items overwrite other slots if we measure it here, so skip measuring
+ // the rest of the slots
+ initialItemsMeasured = laneCount
+ }
+ }
+
+ debugLog {
+ @Suppress("ListIterator")
+ "| current filled, measured items are ${measuredItems.map { it.map { it.index } }}"
+ }
+ debugLog {
+ "| filling down from indices: ${currentItemIndices.toList()}, " +
+ "offsets: ${currentItemOffsets.toList()}"
}
// then composing visible items forward until we fill the whole viewport.
- // we want to have at least one item in visibleItems even if in fact all the items are
+ // we want to have at least one item in measuredItems even if in fact all the items are
// offscreen, this can happen if the content padding is larger than the available size.
while (
currentItemOffsets.any {
@@ -340,74 +447,72 @@
} || measuredItems.all { it.isEmpty() }
) {
val currentLaneIndex = currentItemOffsets.indexOfMinValue()
- val nextItemIndex =
- findNextItemIndex(currentItemIndices[currentLaneIndex], currentLaneIndex)
+ val previousItemIndex = currentItemIndices.max()
+ val itemIndex = previousItemIndex + 1
- if (nextItemIndex >= itemCount) {
- // if any items changed its size, the spans may not behave correctly
- // there are no more items in this lane, but there could be more in others
- // recheck if we can add more items and reset spans accordingly
- var missedItemIndex = Int.MAX_VALUE
- currentItemIndices.forEachIndexed { laneIndex, i ->
- if (laneIndex == currentLaneIndex) return@forEachIndexed
- var itemIndex = findNextItemIndex(i, laneIndex)
- while (itemIndex < itemCount) {
- missedItemIndex = minOf(itemIndex, missedItemIndex)
- spans.setSpan(itemIndex, LazyStaggeredGridSpans.Unset)
- itemIndex = findNextItemIndex(itemIndex, laneIndex)
- }
- }
- // there's at least one missed item which may fit current lane
- if (missedItemIndex != Int.MAX_VALUE && canRestartMeasure) {
- // reset current lane to the missed item index and restart measure
- initialItemIndices[currentLaneIndex] =
- min(initialItemIndices[currentLaneIndex], missedItemIndex)
- return measure(
- initialScrollDelta = initialScrollDelta,
- initialItemIndices = initialItemIndices,
- initialItemOffsets = initialItemOffsets,
- canRestartMeasure = false
- )
- } else {
- break
- }
+ if (itemIndex >= itemCount) {
+ break
}
- if (firstItemIndices[currentLaneIndex] == -1) {
- firstItemIndices[currentLaneIndex] = nextItemIndex
- }
- spans.setSpan(nextItemIndex, currentLaneIndex)
+ val spanRange = itemProvider.getSpanRange(itemIndex, currentLaneIndex)
- val measuredItem =
- measuredItemProvider.getAndMeasure(nextItemIndex, currentLaneIndex)
- currentItemOffsets[currentLaneIndex] += measuredItem.sizeWithSpacings
- measuredItems[currentLaneIndex].addLast(measuredItem)
- currentItemIndices[currentLaneIndex] = nextItemIndex
+ laneInfo.setLane(itemIndex, spanRange.laneInfo)
+ val measuredItem = measuredItemProvider.getAndMeasure(itemIndex, spanRange)
+
+ val offset = currentItemOffsets.maxInRange(spanRange)
+ val gaps = if (spanRange.isFullSpan) {
+ laneInfo.getGaps(itemIndex) ?: IntArray(laneCount)
+ } else {
+ null
+ }
+ spanRange.forEach { lane ->
+ if (gaps != null) {
+ gaps[lane] = offset - currentItemOffsets[lane]
+ }
+ currentItemIndices[lane] = itemIndex
+ currentItemOffsets[lane] = offset + measuredItem.sizeWithSpacings
+ measuredItems[lane].addLast(measuredItem)
+ }
+ laneInfo.setGaps(itemIndex, gaps)
+
+ if (currentItemOffsets[spanRange.start] <= minOffset + mainAxisSpacing) {
+ // We scrolled past measuredItem, and it is not visible anymore. We measured it
+ // for correct positioning of other items, but there's no need to place it.
+ // Mark it as not visible and filter below.
+ measuredItem.isVisible = false
+ }
+ }
+
+ debugLog {
+ @Suppress("ListIterator")
+ "| down filled, measured items are ${measuredItems.map { it.map { it.index } }}"
}
// some measured items are offscreen, remove them from the list and adjust indices/offsets
for (laneIndex in measuredItems.indices) {
val laneItems = measuredItems[laneIndex]
- var offset = currentItemOffsets[laneIndex]
- var inBoundsIndex = 0
- for (i in laneItems.lastIndex downTo 0) {
- val item = laneItems[i]
- offset -= item.sizeWithSpacings
- inBoundsIndex = i
- if (offset <= minOffset + mainAxisSpacing) {
- break
- }
+
+ while (laneItems.size > 1 && !laneItems.first().isVisible) {
+ val item = laneItems.removeFirst()
+ val gaps = if (item.span != 1) laneInfo.getGaps(item.index) else null
+ firstItemOffsets[laneIndex] -=
+ item.sizeWithSpacings + if (gaps == null) 0 else gaps[laneIndex]
}
- // the rest of the items are offscreen, update firstIndex/Offset for lane and remove
- // items from measured list
- for (i in 0 until inBoundsIndex) {
- val item = laneItems.removeFirst()
- firstItemOffsets[laneIndex] -= item.sizeWithSpacings
- }
- if (laneItems.isNotEmpty()) {
- firstItemIndices[laneIndex] = laneItems.first().index
- }
+ firstItemIndices[laneIndex] = laneItems.firstOrNull()?.index ?: Unset
+ }
+
+ if (currentItemIndices.any { it == itemCount - 1 }) {
+ currentItemOffsets.offsetBy(-mainAxisSpacing)
+ }
+
+ debugLog {
+ @Suppress("ListIterator")
+ "| removed invisible items: ${measuredItems.map { it.map { it.index } }}"
+ }
+ debugLog {
+ "| filling back up from indices: ${firstItemIndices.toList()}, " +
+ "offsets: ${firstItemOffsets.toList()}"
}
// we didn't fill the whole viewport with items starting from firstVisibleItemIndex.
@@ -433,7 +538,7 @@
if (previousIndex < 0) {
if (misalignedStart(laneIndex) && canRestartMeasure) {
- spans.reset()
+ laneInfo.reset()
return measure(
initialScrollDelta = scrollDelta,
initialItemIndices = IntArray(firstItemIndices.size) { -1 },
@@ -446,15 +551,21 @@
break
}
- spans.setSpan(previousIndex, laneIndex)
-
+ val spanRange = itemProvider.getSpanRange(previousIndex, laneIndex)
+ laneInfo.setLane(previousIndex, spanRange.laneInfo)
val measuredItem = measuredItemProvider.getAndMeasure(
- previousIndex,
- laneIndex
+ index = previousIndex,
+ spanRange
)
- measuredItems[laneIndex].addFirst(measuredItem)
- firstItemOffsets[laneIndex] += measuredItem.sizeWithSpacings
- firstItemIndices[laneIndex] = previousIndex
+
+ val offset = firstItemOffsets.maxInRange(spanRange)
+ val gaps = if (spanRange.isFullSpan) laneInfo.getGaps(previousIndex) else null
+ spanRange.forEach { lane ->
+ measuredItems[lane].addFirst(measuredItem)
+ firstItemIndices[lane] = previousIndex
+ val gap = if (gaps == null) 0 else gaps[lane]
+ firstItemOffsets[lane] = offset + measuredItem.sizeWithSpacings + gap
+ }
}
scrollDelta += toScrollBack
@@ -467,6 +578,14 @@
}
}
+ debugLog {
+ @Suppress("ListIterator")
+ "| measured: ${measuredItems.map { it.map { it.index } }}"
+ }
+ debugLog {
+ "| first indices: ${firstItemIndices.toList()}, offsets: ${firstItemOffsets.toList()}"
+ }
+
// report the amount of pixels we consumed. scrollDelta can be smaller than
// scrollToBeConsumed if there were not enough items to fill the offered space or it
// can be larger if items were resized, or if, for example, we were previously
@@ -487,12 +606,15 @@
for (laneIndex in measuredItems.indices) {
val laneItems = measuredItems[laneIndex]
for (i in laneItems.indices) {
- val size = laneItems[i].sizeWithSpacings
+ val item = laneItems[i]
+ val gaps = laneInfo.getGaps(item.index)
+ val size = item.sizeWithSpacings + if (gaps == null) 0 else gaps[laneIndex]
if (
i != laneItems.lastIndex &&
firstItemOffsets[laneIndex] != 0 &&
firstItemOffsets[laneIndex] >= size
) {
+
firstItemOffsets[laneIndex] -= size
firstItemIndices[laneIndex] = laneItems[i + 1].index
} else {
@@ -502,6 +624,11 @@
}
}
+ debugLog {
+ "| final first indices: ${firstItemIndices.toList()}, " +
+ "offsets: ${firstItemOffsets.toList()}"
+ }
+
// end measure
val layoutWidth = if (isVertical) {
@@ -526,8 +653,13 @@
}
val item = measuredItems[laneIndex].removeFirst()
+ if (item.lane != laneIndex) {
+ continue
+ }
+
// todo(b/182882362): arrangement support
- val mainAxisOffset = itemScrollOffsets[laneIndex]
+ val spanRange = SpanRange(item.lane, item.span)
+ val mainAxisOffset = itemScrollOffsets.maxInRange(spanRange)
val crossAxisOffset =
if (laneIndex == 0) {
0
@@ -535,8 +667,22 @@
resolvedSlotSums[laneIndex - 1] + crossAxisSpacing * laneIndex
}
+ if (item.placeables.isEmpty()) {
+ // nothing to place, ignore spacings
+ continue
+ }
+
positionedItems += item.position(laneIndex, mainAxisOffset, crossAxisOffset)
- itemScrollOffsets[laneIndex] += item.sizeWithSpacings
+ spanRange.forEach { lane ->
+ itemScrollOffsets[lane] = mainAxisOffset + item.sizeWithSpacings
+ }
+ }
+
+ debugLog {
+ "| positioned: ${positionedItems.map { "${it.index} at ${it.offset}" }.toList()}"
+ }
+ debugLog {
+ "========== MEASURE DONE ==========="
}
// todo: reverse layout support
@@ -573,17 +719,40 @@
}
}
+@JvmInline
+private value class SpanRange private constructor(val packedValue: Long) {
+ constructor(lane: Int, span: Int) : this(packInts(lane, lane + span))
+
+ inline val start get(): Int = unpackInt1(packedValue)
+ inline val end get(): Int = unpackInt2(packedValue)
+ inline val size get(): Int = end - start
+}
+
+private inline fun SpanRange.forEach(block: (Int) -> Unit) {
+ for (i in start until end) {
+ block(i)
+ }
+}
+
private fun IntArray.offsetBy(delta: Int) {
for (i in indices) {
this[i] = this[i] + delta
}
}
-internal fun IntArray.indexOfMinValue(): Int {
+private fun IntArray.maxInRange(indexRange: SpanRange): Int {
+ var max = Int.MIN_VALUE
+ indexRange.forEach {
+ max = maxOf(max, this[it])
+ }
+ return max
+}
+
+internal fun IntArray.indexOfMinValue(minBound: Int = Int.MIN_VALUE): Int {
var result = -1
var min = Int.MAX_VALUE
for (i in indices) {
- if (min > this[i]) {
+ if (this[i] in (minBound + 1) until min) {
min = this[i]
result = i
}
@@ -632,21 +801,20 @@
) {
// reverse traverse to make sure last items are recorded to the latter lanes
for (i in indices.indices.reversed()) {
- while (indices[i] >= itemCount) {
+ while (indices[i] >= itemCount || !laneInfo.assignedToLane(indices[i], i)) {
indices[i] = findPreviousItemIndex(indices[i], i)
}
- if (indices[i] != -1) {
+ if (indices[i] >= 0) {
// reserve item for span
- spans.setSpan(indices[i], i)
+ if (!itemProvider.isFullSpan(indices[i])) {
+ laneInfo.setLane(indices[i], i)
+ }
}
}
}
private fun LazyStaggeredGridMeasureContext.findPreviousItemIndex(item: Int, lane: Int): Int =
- spans.findPreviousItemIndex(item, lane)
-
-private fun LazyStaggeredGridMeasureContext.findNextItemIndex(item: Int, lane: Int): Int =
- spans.findNextItemIndex(item, lane)
+ laneInfo.findPreviousItemIndex(item, lane)
@OptIn(ExperimentalFoundationApi::class)
private class LazyStaggeredGridMeasureProvider(
@@ -654,11 +822,13 @@
private val itemProvider: LazyLayoutItemProvider,
private val measureScope: LazyLayoutMeasureScope,
private val resolvedSlotSums: IntArray,
- private val measuredItemFactory: MeasuredItemFactory
+ private val crossAxisSpacing: Int,
+ private val measuredItemFactory: MeasuredItemFactory,
) {
- private fun childConstraints(slot: Int): Constraints {
+ private fun childConstraints(slot: Int, span: Int): Constraints {
val previousSum = if (slot == 0) 0 else resolvedSlotSums[slot - 1]
- val crossAxisSize = resolvedSlotSums[slot] - previousSum
+ val crossAxisSize =
+ resolvedSlotSums[slot + span - 1] - previousSum + crossAxisSpacing * (span - 1)
return if (isVertical) {
Constraints.fixedWidth(crossAxisSize)
} else {
@@ -666,10 +836,10 @@
}
}
- fun getAndMeasure(index: Int, lane: Int): LazyStaggeredGridMeasuredItem {
+ fun getAndMeasure(index: Int, span: SpanRange): LazyStaggeredGridMeasuredItem {
val key = itemProvider.getKey(index)
- val placeables = measureScope.measure(index, childConstraints(lane))
- return measuredItemFactory.createItem(index, lane, key, placeables)
+ val placeables = measureScope.measure(index, childConstraints(span.start, span.size))
+ return measuredItemFactory.createItem(index, span.start, span.size, key, placeables)
}
}
@@ -678,6 +848,7 @@
fun createItem(
index: Int,
lane: Int,
+ span: Int,
key: Any,
placeables: List<Placeable>
): LazyStaggeredGridMeasuredItem
@@ -689,8 +860,12 @@
val placeables: List<Placeable>,
val isVertical: Boolean,
val contentOffset: IntOffset,
- val spacing: Int
+ val spacing: Int,
+ val lane: Int,
+ val span: Int
) {
+ var isVisible = true
+
val mainAxisSize: Int = placeables.fastFold(0) { size, placeable ->
size + if (isVertical) placeable.height else placeable.width
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt
index a645632..29ec864 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt
@@ -23,7 +23,6 @@
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
-import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -39,7 +38,7 @@
@ExperimentalFoundationApi
internal fun rememberStaggeredGridMeasurePolicy(
state: LazyStaggeredGridState,
- itemProvider: LazyLayoutItemProvider,
+ itemProvider: LazyStaggeredGridItemProvider,
contentPadding: PaddingValues,
reverseLayout: Boolean,
orientation: Orientation,
@@ -67,6 +66,7 @@
// setup information for prefetch
state.laneWidthsPrefixSum = resolvedSlotSums
state.isVertical = isVertical
+ state.spanProvider = itemProvider.spanProvider
val beforeContentPadding = contentPadding.beforePadding(
orientation, reverseLayout, layoutDirection
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope.kt
index 4d748da..364b075 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScope.kt
@@ -21,20 +21,21 @@
import androidx.compose.foundation.lazy.layout.MutableIntervalList
import androidx.compose.runtime.Composable
-@ExperimentalFoundationApi
+@OptIn(ExperimentalFoundationApi::class)
internal class LazyStaggeredGridScopeImpl : LazyStaggeredGridScope {
val intervals = MutableIntervalList<LazyStaggeredGridIntervalContent>()
- @ExperimentalFoundationApi
override fun item(
key: Any?,
contentType: Any?,
+ span: StaggeredGridItemSpan?,
content: @Composable LazyStaggeredGridItemScope.() -> Unit
) {
items(
count = 1,
key = key?.let { { key } },
contentType = { contentType },
+ span = span?.let { { span } },
itemContent = { content() }
)
}
@@ -43,6 +44,7 @@
count: Int,
key: ((index: Int) -> Any)?,
contentType: (index: Int) -> Any?,
+ span: ((index: Int) -> StaggeredGridItemSpan)?,
itemContent: @Composable LazyStaggeredGridItemScope.(index: Int) -> Unit
) {
intervals.addInterval(
@@ -50,6 +52,7 @@
LazyStaggeredGridIntervalContent(
key,
contentType,
+ span,
itemContent
)
)
@@ -63,5 +66,6 @@
internal class LazyStaggeredGridIntervalContent(
override val key: ((index: Int) -> Any)?,
override val type: ((index: Int) -> Any?),
+ val span: ((index: Int) -> StaggeredGridItemSpan)?,
val item: @Composable LazyStaggeredGridItemScope.(Int) -> Unit
) : LazyLayoutIntervalContent
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpan.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpan.kt
new file mode 100644
index 0000000..1379767a
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpan.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.compose.foundation.lazy.staggeredgrid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.IntervalList
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan.Companion.FullLine
+
+/**
+ * Span defines a number of lanes (columns in vertical grid/rows in horizontal grid) for
+ * staggered grid items.
+ * Two variations of span are supported:
+ * - item taking a single lane ([SingleLane]);
+ * - item all lanes in line ([FullLine]).
+ * By default, staggered grid uses [SingleLane] for all items.
+ */
+@ExperimentalFoundationApi
+class StaggeredGridItemSpan private constructor(internal val value: Int) {
+ companion object {
+ /**
+ * Force item to occupy whole line in cross axis.
+ */
+ val FullLine = StaggeredGridItemSpan(0)
+
+ /**
+ * Force item to use a single lane.
+ */
+ val SingleLane = StaggeredGridItemSpan(1)
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyStaggeredGridSpanProvider(
+ val intervals: IntervalList<LazyStaggeredGridIntervalContent>
+) {
+ fun isFullSpan(itemIndex: Int): Boolean {
+ if (itemIndex !in 0 until intervals.size) return false
+ intervals[itemIndex].run {
+ val span = value.span
+ val localIndex = itemIndex - startIndex
+
+ return span != null && span(localIndex) === FullLine
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpans.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpans.kt
deleted file mode 100644
index 5ec8708..0000000
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridSpans.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * 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.compose.foundation.lazy.staggeredgrid
-
-/**
- * Utility class to remember grid lane assignments (spans) in a sliding window relative to requested
- * item position (usually reflected by scroll position).
- * Remembers the maximum range of remembered items is reflected by [MaxCapacity], if index is beyond
- * the bounds, [anchor] moves to reflect new position.
- */
-internal class LazyStaggeredGridSpans {
- private var anchor = 0
- private var spans = IntArray(16)
-
- /**
- * Sets given span for given item index.
- */
- fun setSpan(item: Int, span: Int) {
- require(item >= 0) { "Negative spans are not supported" }
- ensureValidIndex(item)
- spans[item - anchor] = span + 1
- }
-
- /**
- * Get span for given item index.
- * @return span previously recorded for given item or [Unset] if it doesn't exist.
- */
- fun getSpan(item: Int): Int {
- if (item < lowerBound() || item >= upperBound()) {
- return Unset
- }
- return spans[item - anchor] - 1
- }
-
- /**
- * @return upper bound of currently valid span range
- */
- /* @VisibleForTests */
- fun upperBound(): Int = anchor + spans.size
-
- /**
- * @return lower bound of currently valid span range
- */
- /* @VisibleForTests */
- fun lowerBound(): Int = anchor
-
- /**
- * Delete remembered span assignments.
- */
- fun reset() {
- spans.fill(0)
- }
-
- /**
- * Find the previous item relative to [item] set to target span
- * @return found item index or -1 if it doesn't exist.
- */
- fun findPreviousItemIndex(item: Int, target: Int): Int {
- for (i in (item - 1) downTo 0) {
- val span = getSpan(i)
- if (span == target || span == Unset) {
- return i
- }
- }
- return -1
- }
-
- /**
- * Find the next item relative to [item] set to target span
- * @return found item index or [upperBound] if it doesn't exist.
- */
- fun findNextItemIndex(item: Int, target: Int): Int {
- for (i in item + 1 until upperBound()) {
- val span = getSpan(i)
- if (span == target || span == Unset) {
- return i
- }
- }
- return upperBound()
- }
-
- fun ensureValidIndex(requestedIndex: Int) {
- val requestedCapacity = requestedIndex - anchor
-
- if (requestedCapacity in 0 until MaxCapacity) {
- // simplest path - just grow array to given capacity
- ensureCapacity(requestedCapacity + 1)
- } else {
- // requested index is beyond current span bounds
- // rebase anchor so that requested index is in the middle of span array
- val oldAnchor = anchor
- anchor = maxOf(requestedIndex - (spans.size / 2), 0)
- var delta = anchor - oldAnchor
-
- if (delta >= 0) {
- // copy previous span data if delta is smaller than span size
- if (delta < spans.size) {
- spans.copyInto(
- spans,
- destinationOffset = 0,
- startIndex = delta,
- endIndex = spans.size
- )
- }
- // fill the rest of the spans with default values
- spans.fill(0, maxOf(0, spans.size - delta), spans.size)
- } else {
- delta = -delta
- // check if we can grow spans to match delta
- if (spans.size + delta < MaxCapacity) {
- // grow spans and leave space in the start
- ensureCapacity(spans.size + delta + 1, delta)
- } else {
- // otherwise, just move data that fits
- if (delta < spans.size) {
- spans.copyInto(
- spans,
- destinationOffset = delta,
- startIndex = 0,
- endIndex = spans.size - delta
- )
- }
- // fill the rest of the spans with default values
- spans.fill(0, 0, minOf(spans.size, delta))
- }
- }
- }
- }
-
- private fun ensureCapacity(capacity: Int, newOffset: Int = 0) {
- require(capacity <= MaxCapacity) {
- "Requested span capacity $capacity is larger than max supported: $MaxCapacity!"
- }
- if (spans.size < capacity) {
- var newSize = spans.size
- while (newSize < capacity) newSize *= 2
- spans = spans.copyInto(IntArray(newSize), destinationOffset = newOffset)
- }
- }
-
- companion object {
- private const val MaxCapacity = 131_072 // Closest to 100_000, 2 ^ 17
- internal const val Unset = -1
- }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt
index cd08ee3..bf95c2e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt
@@ -27,13 +27,16 @@
import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState.PrefetchHandle
import androidx.compose.foundation.lazy.layout.animateScrollToItem
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridLaneInfo.Companion.Unset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.layout.Remeasurement
import androidx.compose.ui.layout.RemeasurementModifier
import androidx.compose.ui.unit.Constraints
@@ -94,12 +97,13 @@
* This property is observable and when use it in composable function it will be recomposed on
* each scroll, potentially causing performance issues.
*/
- val firstVisibleItemIndex: Int
- get() = scrollPosition.indices.minOfOrNull {
+ val firstVisibleItemIndex: Int by derivedStateOf(structuralEqualityPolicy()) {
+ scrollPosition.indices.minOfOrNull {
// index array can contain -1, indicating lane being empty (cell number > itemCount)
// if any of the lanes are empty, we always on 0th item index
if (it == -1) 0 else it
} ?: 0
+ }
/**
* Current offset of the item with [firstVisibleItemIndex] relative to the container start.
@@ -107,10 +111,19 @@
* This property is observable and when use it in composable function it will be recomposed on
* each scroll, potentially causing performance issues.
*/
- val firstVisibleItemScrollOffset: Int
- get() = scrollPosition.offsets.run {
- if (isEmpty()) 0 else this[scrollPosition.indices.indexOfMinValue()]
+ val firstVisibleItemScrollOffset: Int by derivedStateOf(structuralEqualityPolicy()) {
+ scrollPosition.offsets.let { offsets ->
+ val firstVisibleIndex = firstVisibleItemIndex
+ val indices = scrollPosition.indices
+ var minOffset = Int.MAX_VALUE
+ for (lane in offsets.indices) {
+ if (indices[lane] == firstVisibleIndex) {
+ minOffset = minOf(minOffset, offsets[lane])
+ }
+ }
+ if (minOffset == Int.MAX_VALUE) 0 else minOffset
}
+ }
/** holder for current scroll position **/
internal val scrollPosition = LazyStaggeredGridScrollPosition(
@@ -133,7 +146,7 @@
mutableStateOf(EmptyLazyStaggeredGridLayoutInfo)
/** storage for lane assignments for each item for consistent scrolling in both directions **/
- internal val spans = LazyStaggeredGridSpans()
+ internal val laneInfo = LazyStaggeredGridLaneInfo()
override var canScrollForward: Boolean by mutableStateOf(false)
private set
@@ -170,9 +183,11 @@
/* @VisibleForTesting */
internal var measurePassCount = 0
- /** states required for prefetching **/
+ /** transient information from measure required for prefetching **/
internal var isVertical = false
internal var laneWidthsPrefixSum: IntArray = IntArray(0)
+ internal var spanProvider: LazyStaggeredGridSpanProvider? = null
+ /** prefetch state **/
private var prefetchBaseIndex: Int = -1
private val currentItemPrefetchHandles = mutableMapOf<Int, PrefetchHandle>()
@@ -329,15 +344,15 @@
// find the next item for each line and prefetch if it is valid
targetIndex = if (scrollingForward) {
- spans.findNextItemIndex(previousIndex, lane)
+ laneInfo.findNextItemIndex(previousIndex, lane)
} else {
- spans.findPreviousItemIndex(previousIndex, lane)
+ laneInfo.findPreviousItemIndex(previousIndex, lane)
}
if (
targetIndex !in (0 until info.totalItemsCount) ||
- previousIndex == targetIndex
+ targetIndex in prefetchHandlesUsed
) {
- return
+ break
}
prefetchHandlesUsed += targetIndex
@@ -345,8 +360,12 @@
continue
}
- val crossAxisSize = laneWidthsPrefixSum[lane] -
- if (lane == 0) 0 else laneWidthsPrefixSum[lane - 1]
+ val isFullSpan = spanProvider?.isFullSpan(targetIndex) == true
+ val slot = if (isFullSpan) 0 else lane
+ val span = if (isFullSpan) laneCount else 1
+
+ val crossAxisSize = laneWidthsPrefixSum[slot + span - 1] -
+ if (slot == 0) 0 else laneWidthsPrefixSum[slot - 1]
val constraints = if (isVertical) {
Constraints.fixedWidth(crossAxisSize)
@@ -399,19 +418,22 @@
}
private fun fillNearestIndices(itemIndex: Int, laneCount: Int): IntArray {
- // reposition spans if needed to ensure valid indices
- spans.ensureValidIndex(itemIndex + laneCount)
-
- val span = spans.getSpan(itemIndex)
- val targetLaneIndex =
- if (span == LazyStaggeredGridSpans.Unset) 0 else minOf(span, laneCount)
val indices = IntArray(laneCount)
+ if (spanProvider?.isFullSpan(itemIndex) == true) {
+ indices.fill(itemIndex)
+ return indices
+ }
+
+ // reposition spans if needed to ensure valid indices
+ laneInfo.ensureValidIndex(itemIndex + laneCount)
+ val previousLane = laneInfo.getLane(itemIndex)
+ val targetLaneIndex = if (previousLane == Unset) 0 else minOf(previousLane, laneCount)
// fill lanes before starting index
var currentItemIndex = itemIndex
for (lane in (targetLaneIndex - 1) downTo 0) {
- indices[lane] = spans.findPreviousItemIndex(currentItemIndex, lane)
- if (indices[lane] == -1) {
+ indices[lane] = laneInfo.findPreviousItemIndex(currentItemIndex, lane)
+ if (indices[lane] == Unset) {
indices.fill(-1, toIndex = lane)
break
}
@@ -423,7 +445,7 @@
// fill lanes after starting index
currentItemIndex = itemIndex
for (lane in (targetLaneIndex + 1) until laneCount) {
- indices[lane] = spans.findNextItemIndex(currentItemIndex, lane)
+ indices[lane] = laneInfo.findNextItemIndex(currentItemIndex, lane)
currentItemIndex = indices[lane]
}