[go: nahoru, domu]

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]
         }