[go: nahoru, domu]

Sync tv-foundation's version of ContentInViewModifier to Compose's version

Tv-foundation's version of ContentInViewModifier has fallen behind
Compose's version which now has a queue for BringIntoView requests.
This causes the focused item to not be brought into view when
fast-scrolling.
Additionally, syncing other forked files too.

Test: NA
Bug: b/266909547

Relnote: "Syncing forked code between tv-foundation and compose"

Change-Id: Ia8344a7ec82fcb9bb27b2b5780b1af144e5ebfb4
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
index 73ef276..874bc54 100644
--- a/tv/tv-foundation/build.gradle
+++ b/tv/tv-foundation/build.gradle
@@ -30,7 +30,7 @@
 dependencies {
     api(libs.kotlinStdlib)
 
-    def composeVersion = '1.3.0-rc01'
+    def composeVersion = '1.4.0-alpha04'
 
     implementation(libs.kotlinStdlibCommon)
     implementation("androidx.profileinstaller:profileinstaller:1.2.0")
@@ -106,7 +106,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt",
                 "src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt",
-                "86acc593cd77d52784532163b5ab8156"
+                "01908be77830b70de53736dfab57d9db"
         )
 )
 
@@ -138,7 +138,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt",
-                "a74bfa05e68e2b6c2e108f022dfbfa26"
+                "b27d616f6e4758d9e5cfd721cd74f696"
         )
 )
 
@@ -146,7 +146,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt",
-                "4c69e8a60a068e1e8191ed3840868881"
+                "79d9698efce71af7507adb1f1f13d587"
         )
 )
 
@@ -154,7 +154,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt",
-                "22078ee2f09dce3f39cdc23dc1188a82"
+                "9ad614f60b201360f2c276678674a09d"
         )
 )
 
@@ -162,7 +162,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt",
-                "c58eaf4619972afbee7da7714dc072fc"
+                "a209b6cf2bcd8a0aff71488ab28c215f"
         )
 )
 
@@ -170,7 +170,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt",
-                "d4407572c6550d184133f8b3fd37869f"
+                "5cc9c72197679de004d98b73ffacf038"
         )
 )
 
@@ -194,7 +194,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt",
-                "1d16fbb5025b282ffeb8fe3a63a9de3d"
+                "15ed411b8761387c1c0602b68185e312"
         )
 )
 
@@ -202,7 +202,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt",
-                "c1b403d4fcd43c423b3f1b0433e8bb43"
+                "a42bc6b7859e14871492ff27ca9bd9a2"
         )
 )
 
@@ -216,7 +216,7 @@
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazySemantics.kt",
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListSemantics.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt",
                 "3a1e86a55ea2282c12745717b5a60cfd"
         )
@@ -250,7 +250,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
-                "6f93637153ebd05d9cba7ebaf12311c9"
+                "bf426a9ae63c2195a88cb382e9e8033e"
         )
 )
 
@@ -274,7 +274,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt",
-                "ba8ee64efc5bcd18f28fe9bb9d987166"
+                "838dc7602c43c0210a650340110f5f94"
         )
 )
 
@@ -282,7 +282,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt",
-                "3f91a6975c10c6a49ddc21f7828d7298"
+                "c63daa5bd3a004f08fc14a510765b681"
         )
 )
 
@@ -290,7 +290,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridLayoutInfo.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridLayoutInfo.kt",
-                "b421c5e74856a78982efe0d8a79d10cb"
+                "20f7055a2556d1c8ccd12873b1d8af2a"
         )
 )
 
@@ -298,7 +298,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt",
-                "c600148ddfab1dde9f3ebe8349e77001"
+                "65b541f64ffc6267ebe7852497a4f37f"
         )
 )
 
@@ -306,7 +306,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt",
-                "1277598d36d8507d7bf0305cc629a11c"
+                "1a9f3308a5865beb6b53f547493f8d20"
         )
 )
 
@@ -346,7 +346,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
-                "062f95aa00d36fb1e048aa1ddb8154bc"
+                "2a794820a36acc55a13d95fd65d03f45"
         )
 )
 
@@ -354,15 +354,15 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt",
-                "c6b402b685824ff216650da77063a131"
+                "0e55034861317320888b77f5183b326f"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredItem.kt",
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt",
-                "b9e6230825d8688bf1164abef07b4e14"
+                "8bbfd4cdd2d1f090f51ffb0f2d625309"
         )
 )
 
@@ -370,15 +370,15 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
-                "ab9a58f65e85b4fe4d621e9ed5b2db68"
+                "e36c6adfcd6cef885600d62775de0917"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredLine.kt",
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt",
-                "3b99751e25cebc9945df800ce1aa04f8"
+                "dedf02d724fb6d470f9566dbf6a260f9"
         )
 )
 
@@ -386,7 +386,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
-                "e2bdba6cdbc870ea9607658ec60eb1eb"
+                "04949bb943c61f7a18358c3e5543318e"
         )
 )
 
@@ -408,14 +408,6 @@
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListPinningModifier.kt",
-                "src/main/java/androidx/tv/foundation/lazy/LazyListPinningModifier.kt",
-                "e37450505d13ab0fd1833f136ec8aa3c"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyScopeMarker.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeMarker.kt",
                 "f7b72b3c6bad88868153300b9fbdd922"
@@ -442,7 +434,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListLayoutInfo.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt",
-                "fa1dffc993bdc486e0819c5d8018cda3"
+                "1e2ff3f4fcaa528d1011f32c8a87e100"
         )
 )
 
@@ -458,7 +450,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyAnimateScroll.kt",
                 "src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt",
-                "72859815545394de5b9f7269f1366d21"
+                "f9d4a924665f65ac319b6071358431b9"
         )
 )
 
@@ -466,7 +458,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridAnimateScrollScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt",
-                "315f220a2674a50f82633a725dc39c1b"
+                "d07400716d6139405135ffbfe042b762"
         )
 )
 
@@ -474,15 +466,23 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListAnimateScrollScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt",
-                "d0d48557af324db3af7f4c46a6810026"
+                "c769969ce9a74ee6006d1c0b76b47095"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-		"../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewModifier.kt",
-		"src/main/java/androidx/tv/foundation/ContentInViewModifier.kt",
-                "c08e23de9ddfed42c9dbb7569eef6198"
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewModifier.kt",
+                "src/main/java/androidx/tv/foundation/ContentInViewModifier.kt",
+                "6dec263110d0fe60021cf6fb9c93bd90"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemanticState.kt",
+                "src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt",
+                "ad"
         )
 )
 
@@ -504,6 +504,15 @@
     }
 }
 
+task printCopiedClasses {
+    doLast {
+        copiedClasses.forEach(copiedClass -> {
+            String actualMd5 = generateMd5(copiedClass.originalFilePath)
+            System.out.println(copiedClass.toString(actualMd5))
+        })
+    }
+}
+
 task doCopiesNeedUpdate {
     doLast {
         List<String> failureFiles = new ArrayList<>()
@@ -554,4 +563,14 @@
                 "lastKnownGoodHash='" + lastKnownGoodHash + '\'\n' +
                 "diffCmd='" + "kdiff3 " + originalFilePath + " " + copyFilePath + "\'"
     }
+
+    String toString(String actualHash) {
+        return "copiedClasses.add(\n" +
+                "        new CopiedClass(\n" +
+                "                \"$originalFilePath\",\n" +
+                "                \"$copyFilePath\",\n" +
+                "                \"$actualHash\"\n" +
+                "        )\n" +
+                ")\n"
+    }
 }
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridPinnableContainerTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridPinnableContainerTest.kt
new file mode 100644
index 0000000..ea85603
--- /dev/null
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridPinnableContainerTest.kt
@@ -0,0 +1,666 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.LocalPinnableContainer
+import androidx.compose.ui.layout.PinnableContainer
+import androidx.compose.ui.layout.PinnableContainer.PinnedHandle
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.lazy.list.assertIsNotPlaced
+import androidx.tv.foundation.lazy.list.assertIsPlaced
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@MediumTest
+class LazyGridPinnableContainerTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var pinnableContainer: PinnableContainer? = null
+
+    private val itemSizePx = 10
+    private var itemSize = Dp.Unspecified
+
+    private val composed = mutableSetOf<Int>()
+
+    @Before
+    fun setup() {
+        itemSize = with(rule.density) { itemSizePx.toDp() }
+    }
+
+    @Composable
+    fun Item(index: Int) {
+        Box(
+            Modifier
+                .size(itemSize)
+                .testTag("$index")
+        )
+        DisposableEffect(index) {
+            composed.add(index)
+            onDispose {
+                composed.remove(index)
+            }
+        }
+    }
+
+    @Test
+    fun pinnedItemIsComposedAndPlacedWhenScrolledOut() {
+        val state = TvLazyGridState()
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 1) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(1)
+            runBlocking {
+                state.scrollToItem(3)
+            }
+        }
+
+        rule.waitUntil {
+            // not visible items were disposed
+            !composed.contains(0)
+        }
+
+        rule.runOnIdle {
+            // item 1 is still pinned
+            assertThat(composed).contains(1)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertExists()
+            .assertIsNotDisplayed()
+            .assertIsPlaced()
+    }
+
+    @Test
+    fun itemsBetweenPinnedAndCurrentVisibleAreNotComposed() {
+        val state = TvLazyGridState()
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 1) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(4)
+            }
+        }
+
+        rule.waitUntil {
+            // not visible items were disposed
+            !composed.contains(0)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).doesNotContain(0)
+            assertThat(composed).contains(1)
+            assertThat(composed).doesNotContain(2)
+            assertThat(composed).doesNotContain(3)
+            assertThat(composed).contains(4)
+        }
+    }
+
+    @Test
+    fun pinnedItemAfterVisibleOnesIsComposedAndPlacedWhenScrolledOut() {
+        val state = TvLazyGridState()
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 4) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(4)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible items to be disposed
+            !composed.contains(1)
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+            assertThat(composed).contains(5)
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(0)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible items to be disposed
+            !composed.contains(5)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(0)
+            assertThat(composed).contains(1)
+            assertThat(composed).doesNotContain(2)
+            assertThat(composed).doesNotContain(3)
+            assertThat(composed).contains(4)
+            assertThat(composed).doesNotContain(5)
+        }
+    }
+
+    @Test
+    fun pinnedItemCanBeUnpinned() {
+        val state = TvLazyGridState()
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 1) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        val handle = rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(3)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible items to be disposed
+            !composed.contains(0)
+        }
+
+        rule.runOnIdle {
+            handle.release()
+        }
+
+        rule.waitUntil {
+            // wait for unpinned item to be disposed
+            !composed.contains(1)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsNotPlaced()
+    }
+
+    @Test
+    fun pinnedItemIsStillPinnedWhenReorderedAndNotVisibleAnymore() {
+        val state = TvLazyGridState()
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 3),
+                state = state
+            ) {
+                items(list, key = { it }) { index ->
+                    if (index == 2) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).containsExactly(0, 1, 2)
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            list = listOf(0, 3, 4, 1, 2)
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(1)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).containsExactly(0, 3, 4, 2) // 2 is pinned
+        }
+
+        rule.onNodeWithTag("2")
+            .assertIsPlaced()
+    }
+
+    @Test
+    fun unpinnedWhenTvLazyGridStateChanges() {
+        var state by mutableStateOf(TvLazyGridState(firstVisibleItemIndex = 2))
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 2) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(3)
+            runBlocking {
+                state.scrollToItem(0)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(3)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(2)
+            state = TvLazyGridState()
+        }
+
+        rule.waitUntil {
+            // wait for pinned item to be disposed
+            !composed.contains(2)
+        }
+
+        rule.onNodeWithTag("2")
+            .assertIsNotPlaced()
+    }
+
+    @Test
+    fun pinAfterTvLazyGridStateChange() {
+        var state by mutableStateOf(TvLazyGridState())
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 0) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            state = TvLazyGridState()
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(1)
+            runBlocking {
+                state.scrollToItem(2)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(1)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(0)
+        }
+    }
+
+    @Test
+    fun itemsArePinnedBasedOnGlobalIndexes() {
+        val state = TvLazyGridState(firstVisibleItemIndex = 3)
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                repeat(100) { index ->
+                    item {
+                        if (index == 3) {
+                            pinnableContainer = LocalPinnableContainer.current
+                        }
+                        Item(index)
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(4)
+            runBlocking {
+                state.scrollToItem(6)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(4)
+        }
+
+        rule.runOnIdle {
+            assertThat(composed).contains(3)
+        }
+
+        rule.onNodeWithTag("3")
+            .assertExists()
+            .assertIsNotDisplayed()
+            .assertIsPlaced()
+    }
+
+    @Test
+    fun pinnedItemIsRemovedWhenNotVisible() {
+        val state = TvLazyGridState(3)
+        var itemCount by mutableStateOf(10)
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(itemCount) { index ->
+                    if (index == 3) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+            assertThat(composed).contains(4)
+            runBlocking {
+                state.scrollToItem(0)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(4)
+        }
+
+        rule.runOnIdle {
+            itemCount = 3
+        }
+
+        rule.waitUntil {
+            // wait for pinned item to be disposed
+            !composed.contains(3)
+        }
+
+        rule.onNodeWithTag("3")
+            .assertIsNotPlaced()
+    }
+
+    @Test
+    fun pinnedItemIsRemovedWhenVisible() {
+        val state = TvLazyGridState(0)
+        var items by mutableStateOf(listOf(0, 1, 2))
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(items) { index ->
+                    if (index == 1) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            items = listOf(0, 2)
+        }
+
+        rule.waitUntil {
+            // wait for pinned item to be disposed
+            !composed.contains(1)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsNotPlaced()
+    }
+
+    @Test
+    fun pinnedMultipleTimes() {
+        val state = TvLazyGridState(0)
+        // Arrange.
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.size(itemSize * 2),
+                state = state
+            ) {
+                items(100) { index ->
+                    if (index == 1) {
+                        pinnableContainer = LocalPinnableContainer.current
+                    }
+                    Item(index)
+                }
+            }
+        }
+
+        val handles = mutableListOf<PinnedHandle>()
+        rule.runOnIdle {
+            handles.add(requireNotNull(pinnableContainer).pin())
+            handles.add(requireNotNull(pinnableContainer).pin())
+        }
+
+        rule.runOnIdle {
+            // pinned 3 times in total
+            handles.add(requireNotNull(pinnableContainer).pin())
+            assertThat(composed).contains(0)
+            runBlocking {
+                state.scrollToItem(3)
+            }
+        }
+
+        rule.waitUntil {
+            // wait for not visible item to be disposed
+            !composed.contains(0)
+        }
+
+        while (handles.isNotEmpty()) {
+            rule.runOnIdle {
+                assertThat(composed).contains(1)
+                handles.removeFirst().release()
+            }
+        }
+
+        rule.waitUntil {
+            // wait for pinned item to be disposed
+            !composed.contains(1)
+        }
+    }
+
+    @Test
+    fun pinningIsPropagatedToParentContainer() {
+        var parentPinned = false
+        val parentContainer = object : PinnableContainer {
+            override fun pin(): PinnedHandle {
+                parentPinned = true
+                return PinnedHandle { parentPinned = false }
+            }
+        }
+        // Arrange.
+        rule.setContent {
+            CompositionLocalProvider(LocalPinnableContainer provides parentContainer) {
+                TvLazyVerticalGrid(TvGridCells.Fixed(1)) {
+                    item {
+                        pinnableContainer = LocalPinnableContainer.current
+                        Box(Modifier.size(itemSize))
+                    }
+                }
+            }
+        }
+
+        val handle = rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(parentPinned).isTrue()
+            handle.release()
+        }
+
+        rule.runOnIdle {
+            assertThat(parentPinned).isFalse()
+        }
+    }
+
+    @Test
+    fun parentContainerChange_pinningIsMaintained() {
+        var parent1Pinned = false
+        val parent1Container = object : PinnableContainer {
+            override fun pin(): PinnedHandle {
+                parent1Pinned = true
+                return PinnedHandle { parent1Pinned = false }
+            }
+        }
+        var parent2Pinned = false
+        val parent2Container = object : PinnableContainer {
+            override fun pin(): PinnedHandle {
+                parent2Pinned = true
+                return PinnedHandle { parent2Pinned = false }
+            }
+        }
+        var parentContainer by mutableStateOf<PinnableContainer>(parent1Container)
+        // Arrange.
+        rule.setContent {
+            CompositionLocalProvider(LocalPinnableContainer provides parentContainer) {
+                TvLazyVerticalGrid(TvGridCells.Fixed(1)) {
+                    item {
+                        pinnableContainer = LocalPinnableContainer.current
+                        Box(Modifier.size(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            requireNotNull(pinnableContainer).pin()
+        }
+
+        rule.runOnIdle {
+            assertThat(parent1Pinned).isTrue()
+            assertThat(parent2Pinned).isFalse()
+            parentContainer = parent2Container
+        }
+
+        rule.runOnIdle {
+            assertThat(parent1Pinned).isFalse()
+            assertThat(parent2Pinned).isTrue()
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/BaseLazyListTestWithOrientation.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
index 194c128..146be7a 100644
--- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
@@ -109,7 +109,7 @@
         } else {
             getUnclippedBoundsInRoot().left
         }
-        position.assertIsEqualTo(expected, tolerance = 1.dp)
+        position.assertIsEqualTo(expected, tolerance = 2.dp)
     }
 
     fun SemanticsNodeInteraction.assertStartPositionInRootIsEqualTo(expectedStart: Dp) =
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/BringIntoViewRequestPriorityQueue.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/BringIntoViewRequestPriorityQueue.kt
new file mode 100644
index 0000000..17d95cf
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/BringIntoViewRequestPriorityQueue.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.tv.foundation
+
+import androidx.compose.runtime.collection.mutableVectorOf
+import androidx.compose.ui.geometry.Rect
+import androidx.tv.foundation.ContentInViewModifier.Request
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+import kotlin.coroutines.resume
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Ongoing requests from [ContentInViewModifier.bringChildIntoView], with the invariant that it is
+ * always sorted by overlapping order: each item's bounds completely overlaps the next item.
+ *
+ * Requests are enqueued by calling [enqueue], which inserts the request at the correct position
+ * and cancels and removes any requests that it interrupts. When a request is enqueued, its
+ * continuation has a completion handler set that will remove the request from the queue when
+ * it's cancelled.
+ *
+ * One a request has been enqueued, it cannot be removed without completing the continuation.
+ * This helps prevent leaking requests. Requests are removed in two ways:
+ *  1. By an [enqueue] call for a request that doesn't overlap them, or
+ *  2. By calling [cancelAndRemoveAll], which does exactly what it says.
+ */
+@OptIn(ExperimentalContracts::class)
+internal class BringIntoViewRequestPriorityQueue {
+    private val requests = mutableVectorOf<Request>()
+
+    val size: Int get() = requests.size
+
+    fun isEmpty(): Boolean = requests.isEmpty()
+
+    /**
+     * Adds [request] to the queue, enforcing the invariants of that list:
+     *  - It will be inserted in the correct position to preserve sorted order.
+     *  - Any requests not contains by or containing this request will be evicted.
+     *
+     * After this function is called, [request] will always be either resumed or cancelled
+     * before it's removed from the queue, so the caller no longer needs to worry about
+     * completing it.
+     *
+     *  @return True if the request was enqueued, false if it was not, e.g. because the rect
+     *  function returned null.
+     */
+    fun enqueue(request: Request): Boolean {
+        val requestBounds = request.currentBounds() ?: run {
+            request.continuation.resume(Unit)
+            return false
+        }
+
+        // If the request is cancelled for any reason, remove it from the queue.
+        request.continuation.invokeOnCancellation {
+            requests.remove(request)
+        }
+
+        for (i in requests.indices.reversed()) {
+            val r = requests[i]
+            val rBounds = r.currentBounds() ?: continue
+            val intersection = requestBounds.intersect(rBounds)
+            if (intersection == requestBounds) {
+                // The current item fully contains the new request, so insert it after.
+                requests.add(i + 1, request)
+                return true
+            } else if (intersection != rBounds) {
+                // The new request and the current item do not fully overlap, so cancel the
+                // current item and all requests after it, remove them, then continue the
+                // search to the next-largest request.
+                val cause = CancellationException(
+                    "bringIntoView call interrupted by a newer, non-overlapping call"
+                )
+                for (j in requests.size - 1..i) {
+                    // This mutates the list while iterating, but since we're iterating
+                    // backwards in both cases, it's fine.
+                    // Cancelling the continuation will remove the request from the queue.
+                    requests[i].continuation.cancel(cause)
+                }
+            }
+            // Otherwise the new request fully contains the current item, so keep searching up
+            // the queue.
+        }
+
+        // No existing request contained the new one. Either the new requests contains all
+        // existing requests and it should be the new head of the queue, or all other requests
+        // were removed.
+        requests.add(0, request)
+        return true
+    }
+
+    inline fun forEachFromSmallest(block: (bounds: Rect?) -> Unit) {
+        contract { callsInPlace(block) }
+        requests.forEachReversed { block(it.currentBounds()) }
+    }
+
+    fun resumeAndRemoveAll() {
+        for (i in requests.indices) {
+            requests[i].continuation.resume(Unit)
+        }
+        requests.clear()
+    }
+
+    inline fun resumeAndRemoveWhile(block: (bounds: Rect?) -> Boolean) {
+        contract { callsInPlace(block) }
+        while (requests.isNotEmpty()) {
+            if (block(requests.last().currentBounds())) {
+                requests.removeAt(requests.lastIndex).continuation.resume(Unit)
+            } else {
+                return
+            }
+        }
+    }
+
+    fun cancelAndRemoveAll(cause: Throwable?) {
+        // The continuation completion handler will remove the request from the queue when it's
+        // cancelled, so we need to make a copy of the list before iterating to avoid concurrent
+        // mutation.
+        requests.map { it.continuation }.forEach {
+            it.cancel(cause)
+        }
+        check(requests.isEmpty())
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ContentInViewModifier.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ContentInViewModifier.kt
index 59949c2..6c22786 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ContentInViewModifier.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ContentInViewModifier.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * Copyright 2023 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,190 +18,275 @@
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.Orientation.Horizontal
+import androidx.compose.foundation.gestures.Orientation.Vertical
 import androidx.compose.foundation.gestures.ScrollableState
-import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.onFocusedBoundsChanged
+import androidx.compose.foundation.relocation.BringIntoViewRequester
 import androidx.compose.foundation.relocation.BringIntoViewResponder
 import androidx.compose.foundation.relocation.bringIntoViewResponder
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.OnPlacedModifier
 import androidx.compose.ui.layout.OnRemeasuredModifier
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.toSize
 import kotlin.math.abs
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.job
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
 
+/**
+ * A [Modifier] to be placed on a scrollable container (i.e. [Modifier.scrollableWithPivot]) that
+ * animates the [ScrollableState] to handle [BringIntoViewRequester] requests and keep the
+ * currently-focused child in view when the viewport shrinks.
+ *
+ * Instances of this class should not be directly added to the modifier chain, instead use the
+ * [modifier] property since this class relies on some modifiers that must be specified as modifier
+ * factory functions and can't be implemented as interfaces.
+ */
 @OptIn(ExperimentalFoundationApi::class)
 internal class ContentInViewModifier(
     private val scope: CoroutineScope,
     private val orientation: Orientation,
-    private val scrollableState: ScrollableState,
+    private val scrollState: ScrollableState,
     private val reverseDirection: Boolean,
     private val pivotOffsets: PivotOffsets
-) : BringIntoViewResponder, OnRemeasuredModifier, OnPlacedModifier {
-    private var focusedChild: LayoutCoordinates? = null
-    private var coordinates: LayoutCoordinates? = null
-    private var oldSize: IntSize? = null
+) : BringIntoViewResponder,
+    OnRemeasuredModifier,
+    OnPlacedModifier {
 
-    // These properties are used to detect the case where the viewport size is animated shrinking
-    // while the scroll animation used to keep the focused child in view is still running.
-    private var focusedChildBeingAnimated: LayoutCoordinates? = null
-    private var focusTargetBounds: Rect? by mutableStateOf(null)
-    private var focusAnimationJob: Job? = null
+    /**
+     * Ongoing requests from [bringChildIntoView], with the invariant that it is always sorted by
+     * overlapping order: each item's [Rect] completely overlaps the next item.
+     *
+     * May contain requests whose bounds are too big to fit in the current viewport. This is for
+     * a few reasons:
+     *  1. The viewport may shrink after a request was enqueued, causing a request that fit at the
+     *     time it was enqueued to no longer fit.
+     *  2. The size of the bounds of a request may change after it's added, causing it to grow
+     *     larger than the viewport.
+     *  3. Having complete information about too-big requests allows us to make the right decision
+     *     about what part of the request to bring into view when smaller requests are also present.
+     */
+    private val bringIntoViewRequests = BringIntoViewRequestPriorityQueue()
+
+    /** The [LayoutCoordinates] of this modifier (i.e. the scrollable container). */
+    private var coordinates: LayoutCoordinates? = null
+    private var focusedChild: LayoutCoordinates? = null
+
+    /**
+     * The previous bounds of the [focusedChild] used by [onRemeasured] to calculate when the
+     * focused child is first clipped when scrolling is reversed.
+     */
+    private var focusedChildBoundsFromPreviousRemeasure: Rect? = null
+
+    /**
+     * Set to true when this class is actively animating the scroll to keep the focused child in
+     * view.
+     */
+    private var trackingFocusedChild = false
+
+    /** The size of the scrollable container. */
+    private var viewportSize = IntSize.Zero
+    private var isAnimationRunning = false
+    private val animationState = UpdatableAnimationState()
 
     val modifier: Modifier = this
-        .onFocusedBoundsChanged {
-            focusedChild = it
-        }
+        .onFocusedBoundsChanged { focusedChild = it }
         .bringIntoViewResponder(this)
 
-    override fun onRemeasured(size: IntSize) {
-        val coordinates = coordinates
-        val oldSize = oldSize
-        // We only care when this node becomes smaller than it previously was, so don't care about
-        // the initial measurement.
-        if (oldSize != null && oldSize != size && coordinates?.isAttached == true) {
-            onSizeChanged(coordinates, oldSize)
+    override fun calculateRectForParent(localRect: Rect): Rect {
+        check(viewportSize != IntSize.Zero) {
+            "Expected BringIntoViewRequester to not be used before parents are placed."
         }
-        this.oldSize = size
+        // size will only be zero before the initial measurement.
+        return computeDestination(localRect, viewportSize)
+    }
+
+    override suspend fun bringChildIntoView(localRect: () -> Rect?) {
+        // Avoid creating no-op requests and no-op animations if the request does not require
+        // scrolling or returns null.
+        if (localRect()?.isMaxVisible() != false) return
+
+        suspendCancellableCoroutine { continuation ->
+            val request = Request(currentBounds = localRect, continuation = continuation)
+            // Once the request is enqueued, even if it returns false, the queue will take care of
+            // handling continuation cancellation so we don't need to do that here.
+            if (bringIntoViewRequests.enqueue(request) && !isAnimationRunning) {
+                launchAnimation()
+            }
+        }
     }
 
     override fun onPlaced(coordinates: LayoutCoordinates) {
         this.coordinates = coordinates
     }
 
-    override fun calculateRectForParent(localRect: Rect): Rect {
-        val oldSize = checkNotNull(oldSize) {
-            "Expected BringIntoViewRequester to not be used before parents are placed."
+    override fun onRemeasured(size: IntSize) {
+        val oldSize = viewportSize
+        viewportSize = size
+
+        // Don't care if the viewport grew.
+        if (size >= oldSize) return
+
+        getFocusedChildBounds()?.let { focusedChild ->
+            val previousFocusedChildBounds = focusedChildBoundsFromPreviousRemeasure ?: focusedChild
+            if (!isAnimationRunning && !trackingFocusedChild &&
+                // Resize caused it to go from being fully visible to at least partially
+                // clipped. Need to use the lastFocusedChildBounds to compare with the old size
+                // only to handle the case where scrolling direction is reversed: in that case, when
+                // the child first goes out-of-bounds, it will be out of bounds regardless of which
+                // size we pass in, so the only way to detect the change is to use the previous
+                // bounds.
+                previousFocusedChildBounds.isMaxVisible(oldSize) && !focusedChild.isMaxVisible(size)
+            ) {
+                trackingFocusedChild = true
+                launchAnimation()
+            }
+
+            this.focusedChildBoundsFromPreviousRemeasure = focusedChild
         }
-        // oldSize will only be null before the initial measurement.
-        return computeDestination(localRect, oldSize, pivotOffsets)
     }
 
-    override suspend fun bringChildIntoView(localRect: () -> Rect?) {
-        // TODO(b/241591211) Read the request's bounds lazily in case they change.
-        @Suppress("NAME_SHADOWING")
-        val localRect = localRect() ?: return
-        performBringIntoView(
-            source = localRect,
-            destination = calculateRectForParent(localRect)
-        )
+    private fun getFocusedChildBounds(): Rect? {
+        val coordinates = this.coordinates?.takeIf { it.isAttached } ?: return null
+        val focusedChild = this.focusedChild?.takeIf { it.isAttached } ?: return null
+        return coordinates.localBoundingBoxOf(focusedChild, clipBounds = false)
+    }
+
+    private fun launchAnimation() {
+        check(!isAnimationRunning)
+
+        scope.launch(start = CoroutineStart.UNDISPATCHED) {
+            var cancellationException: CancellationException? = null
+            val animationJob = coroutineContext.job
+
+            try {
+                isAnimationRunning = true
+                scrollState.scroll {
+                    animationState.value = calculateScrollDelta()
+                    animationState.animateToZero(
+                        // This lambda will be invoked on every frame, during the choreographer
+                        // callback.
+                        beforeFrame = { delta ->
+                            // reverseDirection is actually opposite of what's passed in through the
+                            // (vertical|horizontal)Scroll modifiers.
+                            val scrollMultiplier = if (reverseDirection) 1f else -1f
+                            val adjustedDelta = scrollMultiplier * delta
+                            val consumedScroll = scrollMultiplier * scrollBy(adjustedDelta)
+                            if (abs(consumedScroll) < abs(delta)) {
+                                // If the scroll state didn't consume all the scroll on this frame,
+                                // it probably won't consume any more later either (we might have
+                                // hit the scroll bounds). This is a terminal condition for the
+                                // animation: If we don't cancel it, it could loop forever asking
+                                // for a scroll that will never be consumed.
+                                // Note this will cancel all pending BIV jobs.
+                                // TODO(b/239671493) Should this trigger nested scrolling?
+                                animationJob.cancel(
+                                    "Scroll animation cancelled because scroll was not consumed " +
+                                        "($consumedScroll < $delta)"
+                                )
+                            }
+                        },
+                        // This lambda will be invoked on every frame, but will be dispatched to run
+                        // after the choreographer callback, and after any composition and layout
+                        // passes for the frame. This means that the scroll performed in the above
+                        // lambda will have been applied to the layout nodes.
+                        afterFrame = {
+                            // Complete any BIV requests that were satisfied by this scroll
+                            // adjustment.
+                            bringIntoViewRequests.resumeAndRemoveWhile { bounds ->
+                                // If a request is no longer attached, remove it.
+                                if (bounds == null) return@resumeAndRemoveWhile true
+                                bounds.isMaxVisible()
+                            }
+
+                            // Stop tracking any KIV requests that were satisfied by this scroll
+                            // adjustment.
+                            if (trackingFocusedChild &&
+                                getFocusedChildBounds()?.isMaxVisible() == true
+                            ) {
+                                trackingFocusedChild = false
+                            }
+
+                            // Compute a new scroll target taking into account any resizes,
+                            // replacements, or added/removed requests since the last frame.
+                            animationState.value = calculateScrollDelta()
+                        }
+                    )
+                }
+
+                // Complete any BIV requests if the animation didn't need to run, or if there were
+                // requests that were too large to satisfy. Note that if the animation was
+                // cancelled, this won't run, and the requests will be cancelled instead.
+                bringIntoViewRequests.resumeAndRemoveAll()
+            } catch (e: CancellationException) {
+                cancellationException = e
+                throw e
+            } finally {
+                isAnimationRunning = false
+                // Any BIV requests that were not completed should be considered cancelled.
+                bringIntoViewRequests.cancelAndRemoveAll(cancellationException)
+                trackingFocusedChild = false
+            }
+        }
     }
 
     /**
-     * Handles when the size of the scroll viewport changes by making sure any focused child is kept
-     * appropriately visible when the viewport shrinks and would otherwise hide it.
-     *
-     * One common instance of this is when a text field in a scrollable near the bottom is focused
-     * while the soft keyboard is hidden, causing the keyboard to show, and cover the field.
-     * See b/192043120 and related bugs.
-     *
-     * To future debuggers of this method, it might be helpful to add a draw modifier to the chain
-     * above to draw the focus target bounds:
-     * ```
-     * .drawWithContent {
-     *   drawContent()
-     *   focusTargetBounds?.let {
-     *     drawRect(
-     *       Color.Red,
-     *       topLeft = it.topLeft,
-     *       size = it.size,
-     *       style = Stroke(1.dp.toPx())
-     *     )
-     *   }
-     * }
-     * ```
+     * Calculates how far we need to scroll to satisfy all existing BringIntoView requests and the
+     * focused child tracking.
      */
-    private fun onSizeChanged(coordinates: LayoutCoordinates, oldSize: IntSize) {
-        val containerShrunk = if (orientation == Orientation.Horizontal) {
-            coordinates.size.width < oldSize.width
-        } else {
-            coordinates.size.height < oldSize.height
+    private fun calculateScrollDelta(): Float {
+        if (viewportSize == IntSize.Zero) return 0f
+
+        val rectangleToMakeVisible: Rect = findBringIntoViewRequest()
+            ?: (if (trackingFocusedChild) getFocusedChildBounds() else null)
+            ?: return 0f
+
+        val size = viewportSize.toSize()
+        return when (orientation) {
+            Vertical -> relocationDistance(
+                rectangleToMakeVisible.top,
+                rectangleToMakeVisible.bottom,
+                size.height
+            )
+
+            Horizontal -> relocationDistance(
+                rectangleToMakeVisible.left,
+                rectangleToMakeVisible.right,
+                size.width
+            )
         }
-        // If the container is growing, then if the focused child is only partially visible it will
-        // soon be _more_ visible, so don't scroll.
-        if (!containerShrunk) return
+    }
 
-        val focusedChild = focusedChild?.takeIf { it.isAttached } ?: return
-        val focusedBounds = coordinates.localBoundingBoxOf(focusedChild, clipBounds = false)
-
-        // In order to check if we need to scroll to bring the focused child into view, it's not
-        // enough to consider where the child actually is right now. If the viewport was recently
-        // shrunk, we may have already started a scroll animation to bring it into view. In that
-        // case, we need to compare with the target of the animation, not the current position. If
-        // we don't do that, then in some cases when the viewport size is being animated (e.g. when
-        // the keyboard insets are being animated on API 30+) we might stop trying to keep the
-        // focused child in view before the viewport animation is finished, and the scroll animation
-        // will stop short and leave the focused child out of the viewport. See b/230756508.
-        val eventualFocusedBounds = if (focusedChild === focusedChildBeingAnimated) {
-            // A previous call to this method started an animation that is still running, so compare
-            // with the target of that animation.
-            checkNotNull(focusTargetBounds)
-        } else {
-            focusedBounds
-        }
-
-        val myOldBounds = Rect(Offset.Zero, oldSize.toSize())
-        if (!myOldBounds.overlaps(eventualFocusedBounds)) {
-            // The focused child was not visible before the resize, so we don't need to keep
-            // it visible.
-            return
-        }
-
-        val targetBounds = computeDestination(eventualFocusedBounds, coordinates.size, pivotOffsets)
-        if (targetBounds == eventualFocusedBounds) {
-            // The focused child is already fully visible (not clipped or hidden) after the resize,
-            // or will be after it finishes animating, so we don't need to do anything.
-            return
-        }
-
-        // If execution has gotten to this point, it means the focused child was at least partially
-        // visible before the resize, and it is either partially clipped or completely hidden after
-        // the resize, so we need to adjust scroll to keep it in view.
-        focusedChildBeingAnimated = focusedChild
-        focusTargetBounds = targetBounds
-        scope.launch(NonCancellable) {
-            val job = launch {
-                // Animate the scroll offset to keep the focused child in view. This is a suspending
-                // call that will suspend until the animation is finished, and only return if it
-                // completes. If any other scroll operations are performed after the animation starts,
-                // e.g. the viewport shrinks again or the user manually scrolls, this animation will
-                // be cancelled and this function will throw a CancellationException.
-                performBringIntoView(source = focusedBounds, destination = targetBounds)
-            }
-            focusAnimationJob = job
-
-            // If the scroll was interrupted by another viewport shrink that happens while the
-            // animation is running, we don't want to clear these fields since the later call to
-            // this onSizeChanged method will have updated the fields with its own values.
-            // If the animation completed, or was cancelled for any other reason, we need to clear
-            // them so the next viewport shrink doesn't think there's already a scroll animation in
-            // progress.
-            // Doing this wrong has a few implications:
-            // 1. If the fields are nulled out when another onSizeChange call happens, it will not
-            //    use the current animation target and viewport animations will lose track of the
-            //    focusable.
-            // 2. If the fields are not nulled out in other cases, the next viewport animation will
-            //    not keep the focusable in view if the focus hasn't changed.
-            try {
-                job.join()
-            } finally {
-                if (focusAnimationJob === job) {
-                    focusedChildBeingAnimated = null
-                    focusTargetBounds = null
-                    focusAnimationJob = null
-                }
+    /**
+     * Find the largest BIV request that can completely fit inside the viewport.
+     */
+    private fun findBringIntoViewRequest(): Rect? {
+        var rectangleToMakeVisible: Rect? = null
+        bringIntoViewRequests.forEachFromSmallest { bounds ->
+            // Ignore detached requests for now. They'll be removed later.
+            if (bounds == null) return@forEachFromSmallest
+            if (bounds.size <= viewportSize.toSize()) {
+                rectangleToMakeVisible = bounds
+            } else {
+                // Found a request that doesn't fit, use the next-smallest one.
+                // TODO(klippenstein) if there is a request that's too big to fit in the current
+                //  bounds, we should try to fit the largest part of it that contains the
+                //  next-smallest request.
+                return rectangleToMakeVisible ?: bounds
             }
         }
+        return rectangleToMakeVisible
     }
 
     /**
@@ -210,50 +295,50 @@
      * @param childBounds The bounding box of the item that sent the request to be brought into view.
      * @return the destination rectangle.
      */
-    private fun computeDestination(
-        childBounds: Rect,
-        containerSize: IntSize,
-        pivotOffsets: PivotOffsets
-    ): Rect {
-        val size = containerSize.toSize()
-        return when (orientation) {
-            Orientation.Vertical ->
-                childBounds.translate(
-                    translateX = 0f,
-                    translateY = -relocationDistance(
-                        childBounds.top,
-                        childBounds.bottom,
-                        size.height,
-                        pivotOffsets
-                    )
-                )
-
-            Orientation.Horizontal ->
-                childBounds.translate(
-                    translateX = -relocationDistance(
-                        childBounds.left,
-                        childBounds.right,
-                        size.width,
-                        pivotOffsets
-                    ),
-                    translateY = 0f
-                )
-        }
+    private fun computeDestination(childBounds: Rect, containerSize: IntSize): Rect {
+        return childBounds.translate(-relocationOffset(childBounds, containerSize))
     }
 
+    // According to a comment in LazyListState.onScroll()
+    // "inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
+    // we have less than 0.5 pixels"
+    private val scrollableThreshold = 0.5f
     /**
-     * Using the source and destination bounds, perform an animated scroll.
+     * Returns true if this [Rect] is as visible as it can be given the [size] of the viewport.
+     * This means either it's fully visible or too big to fit in the viewport all at once and
+     * already filling the whole viewport.
      */
-    private suspend fun performBringIntoView(source: Rect, destination: Rect) {
-        val offset = when (orientation) {
-            Orientation.Vertical -> destination.top - source.top
-            Orientation.Horizontal -> destination.left - source.left
-        }
-        val scrollDelta = if (reverseDirection) -offset else offset
+    private fun Rect.isMaxVisible(size: IntSize = viewportSize): Boolean {
+        // According to a comment in LazyListState.onScroll()
+        // "inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
+        // we have less than 0.5 pixels". So, if any of the offsets are less than 0.5, it will be
+        // considered to be max-visible
+        val relocationOffset = relocationOffset(this, size)
+        return abs(relocationOffset.x) <= scrollableThreshold &&
+            abs(relocationOffset.y) <= scrollableThreshold
+    }
 
-        // Note that this results in weird behavior if called before the previous
-        // performBringIntoView finishes due to b/220119990.
-        scrollableState.animateScrollBy(scrollDelta)
+    private fun relocationOffset(childBounds: Rect, containerSize: IntSize): Offset {
+        val size = containerSize.toSize()
+        return when (orientation) {
+            Vertical -> Offset(
+                x = 0f,
+                y = relocationDistance(
+                    childBounds.top,
+                    childBounds.bottom,
+                    size.height
+                )
+            )
+
+            Horizontal -> Offset(
+                x = relocationDistance(
+                    childBounds.left,
+                    childBounds.right,
+                    size.width
+                ),
+                y = 0f
+            )
+        }
     }
 
     /**
@@ -265,24 +350,46 @@
     private fun relocationDistance(
         leadingEdgeOfItemRequestingFocus: Float,
         trailingEdgeOfItemRequestingFocus: Float,
-        parentSize: Float,
-        pivotOffsets: PivotOffsets
+        containerSize: Float
     ): Float {
         val sizeOfItemRequestingFocus =
             abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
-        val childSmallerThanParent = sizeOfItemRequestingFocus <= parentSize
+        val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
         val initialTargetForLeadingEdge =
-            pivotOffsets.parentFraction * parentSize -
+            pivotOffsets.parentFraction * containerSize -
                 (pivotOffsets.childFraction * sizeOfItemRequestingFocus)
-        val spaceAvailableToShowItem = parentSize - initialTargetForLeadingEdge
+        val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
 
         val targetForLeadingEdge =
             if (childSmallerThanParent && spaceAvailableToShowItem < sizeOfItemRequestingFocus) {
-                parentSize - sizeOfItemRequestingFocus
+                containerSize - sizeOfItemRequestingFocus
             } else {
                 initialTargetForLeadingEdge
             }
 
         return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
     }
-}
+
+    private operator fun IntSize.compareTo(other: IntSize): Int = when (orientation) {
+        Horizontal -> width.compareTo(other.width)
+        Vertical -> height.compareTo(other.height)
+    }
+
+    private operator fun Size.compareTo(other: Size): Int = when (orientation) {
+        Horizontal -> width.compareTo(other.width)
+        Vertical -> height.compareTo(other.height)
+    }
+
+    /**
+     * A request to bring some [Rect] in the scrollable viewport.
+     *
+     * @param currentBounds A function that returns the current bounds that the request wants to
+     * make visible.
+     * @param continuation The [CancellableContinuation] from the suspend function used to make the
+     * request.
+     */
+    internal class Request(
+        val currentBounds: () -> Rect?,
+        val continuation: CancellableContinuation<Unit>,
+    )
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
index 619f0a2..9d7df7f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
@@ -1,3 +1,4 @@
+
 /*
  * Copyright 2022 The Android Open Source Project
  *
@@ -123,15 +124,14 @@
     val reverseDirection: Boolean,
     val scrollableState: ScrollableState,
 ) {
-    private fun Float.toOffset(): Offset = when {
+    fun Float.toOffset(): Offset = when {
         this == 0f -> Offset.Zero
         orientation == Horizontal -> Offset(this, 0f)
         else -> Offset(0f, this)
     }
 
-    private fun Offset.toFloat(): Float =
-        if (orientation == Horizontal) this.x else this.y
-    private fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
+    fun Offset.toFloat(): Float = if (orientation == Horizontal) this.x else this.y
+    fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
 
     fun performRawScroll(scroll: Offset): Offset {
         return if (scrollableState.isScrollInProgress) {
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/UpdatableAnimationState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/UpdatableAnimationState.kt
new file mode 100644
index 0000000..6255031
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/UpdatableAnimationState.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.tv.foundation
+
+import android.view.animation.AccelerateDecelerateInterpolator
+import androidx.compose.animation.core.AnimationConstants.UnspecifiedTime
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.MotionDurationScale
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+import kotlin.coroutines.coroutineContext
+import kotlin.math.absoluteValue
+import kotlin.math.roundToLong
+
+/**
+ * Holds state for an [animation][animateToZero] that will continuously animate a float [value] to
+ * zero.
+ *
+ * Unlike the standard [AnimationState], this class allows the value to be changed while the
+ * animation is running. When that happens, the next frame will continue animating the new value
+ * to zero as though the previous animation was interrupted and restarted with the new value. See
+ * the docs on [animateToZero] for more information.
+ *
+ * An analogy for how this animation works is gravity – you can pick something up, and as soon as
+ * you let it go it will start falling to the ground. If you catch it and raise it higher, it will
+ * continue falling from the new height.
+ *
+ * Similar behavior could be achieved by using an [AnimationState] and creating a new copy and
+ * launching a new coroutine to call `animateTo(0f)` every time the value changes. However, this
+ * class doesn't require allocating a new state object and launching/cancelling a coroutine to
+ * update the value, which makes for a more convenient API for this particular use case, and makes
+ * it cheaper to update [value] on every frame.
+ */
+internal class UpdatableAnimationState {
+
+    private var lastFrameTime = UnspecifiedTime
+    private var lastVelocity = ZeroVector
+    private var isRunning = false
+
+    /**
+     * The value to be animated. This property will be changed on every frame while [animateToZero]
+     * is running, and will be set to exactly 0f before it returns. Unlike [AnimationState], this
+     * property is mutable – it can be changed it any time during the animation, and the animation
+     * will continue running from the new value on the next frame.
+     *
+     * Simply setting this property will not start the animation – [animateToZero] must be manually
+     * invoked to kick off the animation, but once it's running it does not need to be called again
+     * when this property is changed, until the animation finishes.
+     */
+    var value: Float = 0f
+
+    /**
+     * Starts animating [value] to 0f. This function will suspend until [value] actually reaches
+     * 0f – e.g. if [value] is reset to a non-zero value on every frame, it will never return. When
+     * this function does return, [value] will have been set to exactly 0f.
+     *
+     * If this function is called more than once concurrently, it will throw.
+     *
+     * @param beforeFrame Called _inside_ the choreographer callback on every frame with the
+     * difference between the previous value and the new value. This corresponds to the typical
+     * frame callback used in the other animation APIs and [withFrameNanos]. It runs before
+     * composition, layout, and other passes for the frame.
+     * @param afterFrame Called _outside_ the choreographer callback for every frame, _after_ the
+     * composition and layout passes have finished running for that frame. This function allows the
+     * caller to update [value] based on any layout changes performed in [beforeFrame].
+     */
+    @OptIn(ExperimentalContracts::class)
+    suspend fun animateToZero(
+        beforeFrame: (valueDelta: Float) -> Unit,
+        afterFrame: () -> Unit
+    ) {
+        contract { callsInPlace(beforeFrame) }
+        check(!isRunning)
+
+        val durationScale = coroutineContext[MotionDurationScale]?.scaleFactor ?: 1f
+        isRunning = true
+
+        try {
+            // Don't rely on the animation's duration vs playtime to calculate completion since the
+            // value could be updated after each frame, and if that happens we need to continue
+            // running the animation.
+            while (!value.isZeroish()) {
+                withFrameNanos { frameTime ->
+                    if (lastFrameTime == UnspecifiedTime) {
+                        lastFrameTime = frameTime
+                    }
+
+                    val vectorizedCurrentValue = AnimationVector1D(value)
+                    val playTime = if (durationScale == 0f) {
+                        // The duration scale will be 0 when animations are disabled via a11y
+                        // settings or developer settings.
+                        RebasableAnimationSpec.getDurationNanos(
+                            initialValue = AnimationVector1D(value),
+                            targetValue = ZeroVector,
+                            initialVelocity = lastVelocity
+                        )
+                    } else {
+                        ((frameTime - lastFrameTime) / durationScale).roundToLong()
+                    }
+                    val newValue = RebasableAnimationSpec.getValueFromNanos(
+                        playTimeNanos = playTime,
+                        initialValue = vectorizedCurrentValue,
+                        targetValue = ZeroVector,
+                        initialVelocity = lastVelocity
+                    ).value
+                    lastVelocity = RebasableAnimationSpec.getVelocityFromNanos(
+                        playTimeNanos = playTime,
+                        initialValue = vectorizedCurrentValue,
+                        targetValue = ZeroVector,
+                        initialVelocity = lastVelocity
+                    )
+                    lastFrameTime = frameTime
+
+                    val delta = value - newValue
+                    value = newValue
+                    beforeFrame(delta)
+                }
+                afterFrame()
+
+                if (durationScale == 0f) {
+                    // Never run more than one loop when animations are disabled.
+                    break
+                }
+            }
+
+            // The last iteration of the loop may have called block with a non-zero value due to
+            // the visibility threshold, so ensure it gets called one last time with actual zero.
+            if (value.absoluteValue != 0f) {
+                withFrameNanos {
+                    val delta = value
+                    // Update the value before invoking the callback so that the callback will see
+                    // the correct value if it looks at it.
+                    value = 0f
+                    beforeFrame(delta)
+                }
+                afterFrame()
+            }
+        } finally {
+            lastFrameTime = UnspecifiedTime
+            lastVelocity = ZeroVector
+            isRunning = false
+        }
+    }
+
+    private companion object {
+        const val VisibilityThreshold = 0.01f
+        val ZeroVector = AnimationVector1D(0f)
+
+        /**
+         * Spring does not work well with PivotOffset version of this class. Using
+         * [AccelerateDecelerateInterpolator] that is used by RecyclerView by default
+         */
+        val AccelerateDecelerateInterpolatorEasing = Easing {
+            AccelerateDecelerateInterpolator().getInterpolation(it)
+        }
+        val RebasableAnimationSpec =
+            tween<Float>(durationMillis = 30, easing = AccelerateDecelerateInterpolatorEasing)
+                .vectorize(Float.VectorConverter)
+
+        fun Float.isZeroish() = absoluteValue < VisibilityThreshold
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
index 3536deb..9d04904 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
@@ -77,7 +77,7 @@
 ) {
     val itemProvider = rememberLazyGridItemProvider(state, content)
 
-    val semanticState = rememberLazyGridSemanticState(state, itemProvider, reverseLayout)
+    val semanticState = rememberLazyGridSemanticState(state, reverseLayout)
 
     val scope = rememberCoroutineScope()
     val placementAnimator = remember(state, isVertical) {
@@ -110,7 +110,8 @@
                 itemProvider = itemProvider,
                 state = semanticState,
                 orientation = orientation,
-                userScrollEnabled = userScrollEnabled
+                userScrollEnabled = userScrollEnabled,
+                reverseScrolling = reverseLayout
             )
             .clipScrollableContainer(orientation)
             .scrollableWithPivot(
@@ -324,6 +325,7 @@
         }
         measureLazyGrid(
             itemsCount = itemsCount,
+            itemProvider = itemProvider,
             measuredLineProvider = measuredLineProvider,
             measuredItemProvider = measuredItemProvider,
             mainAxisAvailableSize = mainAxisAvailableSize,
@@ -341,6 +343,7 @@
             density = this,
             placementAnimator = placementAnimator,
             spanLayoutProvider = spanLayoutProvider,
+            pinnedItems = state.pinnedItems,
             layout = { width, height, placement ->
                 layout(
                     containerConstraints.constrainWidth(width + totalHorizontalPadding),
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
index 44ddb82..f001d49 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.lazy.layout.DelegatingLazyLayoutItemProvider
 import androidx.compose.foundation.lazy.layout.IntervalList
 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem
 import androidx.compose.foundation.lazy.layout.rememberLazyNearestItemsRangeState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
@@ -57,6 +58,7 @@
             LazyGridItemProviderImpl(
                 gridScope.intervals,
                 gridScope.hasCustomSpans,
+                state,
                 nearestItemsRangeState.value
             )
         }
@@ -81,12 +83,20 @@
 private class LazyGridItemProviderImpl(
     private val intervals: IntervalList<LazyGridIntervalContent>,
     override val hasCustomSpans: Boolean,
+    state: TvLazyGridState,
     nearestItemsRange: IntRange
 ) : LazyGridItemProvider, LazyLayoutItemProvider by LazyLayoutItemProvider(
     intervals = intervals,
     nearestItemsRange = nearestItemsRange,
     itemContent = { interval, index ->
-        interval.value.item.invoke(TvLazyGridItemScopeImpl, index - interval.startIndex)
+        val localIndex = index - interval.startIndex
+        LazyLayoutPinnableItem(
+            key = interval.value.key?.invoke(localIndex),
+            index = index,
+            pinnedItemList = state.pinnedItems
+        ) {
+            interval.value.item.invoke(TvLazyGridItemScopeImpl, localIndex)
+        }
     }
 ) {
     override val spanLayoutProvider: LazyGridSpanLayoutProvider =
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
index 240856e..9824add 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
@@ -16,8 +16,10 @@
 
 package androidx.tv.foundation.lazy.grid
 
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
@@ -27,6 +29,7 @@
 import androidx.compose.ui.unit.constrainWidth
 import androidx.compose.ui.util.fastForEach
 import androidx.compose.ui.util.fastSumBy
+import androidx.tv.foundation.lazy.list.fastFilter
 import kotlin.math.abs
 import kotlin.math.min
 import kotlin.math.roundToInt
@@ -36,8 +39,11 @@
  * Measures and calculates the positions for the currently visible items. The result is produced
  * as a [TvLazyGridMeasureResult] which contains all the calculations.
  */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
 internal fun measureLazyGrid(
     itemsCount: Int,
+    itemProvider: LazyGridItemProvider,
     measuredLineProvider: LazyMeasuredLineProvider,
     measuredItemProvider: LazyMeasuredItemProvider,
     mainAxisAvailableSize: Int,
@@ -55,6 +61,7 @@
     density: Density,
     placementAnimator: LazyGridItemPlacementAnimator,
     spanLayoutProvider: LazyGridSpanLayoutProvider,
+    pinnedItems: LazyLayoutPinnedItemList,
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): TvLazyGridMeasureResult {
     require(beforeContentPadding >= 0)
@@ -146,7 +153,6 @@
         ) {
             val measuredLine = measuredLineProvider.getAndMeasure(index)
             if (measuredLine.isEmpty()) {
-                --index
                 break
             }
 
@@ -202,6 +208,24 @@
         val visibleLinesScrollOffset = -currentFirstLineScrollOffset
         var firstLine = visibleLines.first()
 
+        val firstItemIndex = firstLine.items.firstOrNull()?.index?.value ?: 0
+        val lastItemIndex = visibleLines.lastOrNull()?.items?.lastOrNull()?.index?.value ?: 0
+        val extraItemsBefore = calculateExtraItems(
+            pinnedItems,
+            measuredItemProvider,
+            itemProvider,
+            itemConstraints = { measuredLineProvider.itemConstraints(it) },
+            filter = { it in 0 until firstItemIndex }
+        )
+
+        val extraItemsAfter = calculateExtraItems(
+            pinnedItems,
+            measuredItemProvider,
+            itemProvider,
+            itemConstraints = { measuredLineProvider.itemConstraints(it) },
+            filter = { it in (lastItemIndex + 1) until itemsCount }
+        )
+
         // even if we compose lines to fill before content padding we should ignore lines fully
         // located there for the state's scroll position calculation (first line + first offset)
         if (beforeContentPadding > 0 || spaceBetweenLines < 0) {
@@ -230,6 +254,8 @@
 
         val positionedItems = calculateItemsOffsets(
             lines = visibleLines,
+            itemsBefore = extraItemsBefore,
+            itemsAfter = extraItemsAfter,
             layoutWidth = layoutWidth,
             layoutHeight = layoutHeight,
             finalMainAxisOffset = currentMainAxisOffset,
@@ -251,19 +277,24 @@
             spanLayoutProvider = spanLayoutProvider
         )
 
-        val lastVisibleItemIndex = visibleLines.lastOrNull()?.items?.lastOrNull()?.index?.value ?: 0
         return TvLazyGridMeasureResult(
             firstVisibleLine = firstLine,
             firstVisibleLineScrollOffset = currentFirstLineScrollOffset,
             canScrollForward =
-            lastVisibleItemIndex != itemsCount - 1 || currentMainAxisOffset > maxOffset,
+            lastItemIndex != itemsCount - 1 || currentMainAxisOffset > maxOffset,
             consumedScroll = consumedScroll,
             measureResult = layout(layoutWidth, layoutHeight) {
                 positionedItems.fastForEach { it.place(this) }
             },
             viewportStartOffset = -beforeContentPadding,
             viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
-            visibleItemsInfo = positionedItems,
+            visibleItemsInfo = if (extraItemsBefore.isEmpty() && extraItemsAfter.isEmpty()) {
+                positionedItems
+            } else {
+                positionedItems.fastFilter {
+                    it.index in firstItemIndex..lastItemIndex
+                }
+            },
             totalItemsCount = itemsCount,
             reverseLayout = reverseLayout,
             orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
@@ -273,11 +304,43 @@
     }
 }
 
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+private inline fun calculateExtraItems(
+    pinnedItems: LazyLayoutPinnedItemList,
+    measuredItemProvider: LazyMeasuredItemProvider,
+    itemProvider: LazyGridItemProvider,
+    itemConstraints: (ItemIndex) -> Constraints,
+    filter: (Int) -> Boolean
+): List<LazyMeasuredItem> {
+    var items: MutableList<LazyMeasuredItem>? = null
+
+    pinnedItems.fastForEach { item ->
+        val index = itemProvider.findIndexByKey(item.key, item.index)
+        if (filter(index)) {
+            val itemIndex = ItemIndex(index)
+            val constraints = itemConstraints(itemIndex)
+            val measuredItem = measuredItemProvider.getAndMeasure(
+                itemIndex,
+                constraints = constraints
+            )
+            if (items == null) {
+                items = mutableListOf()
+            }
+            items?.add(measuredItem)
+        }
+    }
+
+    return items ?: emptyList()
+}
+
 /**
  * Calculates [LazyMeasuredLine]s offsets.
  */
 private fun calculateItemsOffsets(
     lines: List<LazyMeasuredLine>,
+    itemsBefore: List<LazyMeasuredItem>,
+    itemsAfter: List<LazyMeasuredItem>,
     layoutWidth: Int,
     layoutHeight: Int,
     finalMainAxisOffset: Int,
@@ -298,6 +361,7 @@
     val positionedItems = ArrayList<LazyGridPositionedItem>(lines.fastSumBy { it.items.size })
 
     if (hasSpareSpace) {
+        require(itemsBefore.isEmpty() && itemsAfter.isEmpty())
         val linesCount = lines.size
         fun Int.reverseAware() =
             if (!reverseLayout) this else linesCount - this - 1
@@ -336,10 +400,36 @@
         }
     } else {
         var currentMainAxis = firstLineScrollOffset
+
+        itemsBefore.fastForEach {
+            currentMainAxis -= it.mainAxisSizeWithSpacings
+            positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight))
+        }
+
+        currentMainAxis = firstLineScrollOffset
         lines.fastForEach {
             positionedItems.addAll(it.position(currentMainAxis, layoutWidth, layoutHeight))
             currentMainAxis += it.mainAxisSizeWithSpacings
         }
+
+        itemsAfter.fastForEach {
+            positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight))
+            currentMainAxis += it.mainAxisSizeWithSpacings
+        }
     }
     return positionedItems
 }
+
+private fun LazyMeasuredItem.positionExtraItem(
+    mainAxisOffset: Int,
+    layoutWidth: Int,
+    layoutHeight: Int
+): LazyGridPositionedItem =
+    position(
+        mainAxisOffset = mainAxisOffset,
+        crossAxisOffset = 0,
+        layoutWidth = layoutWidth,
+        layoutHeight = layoutHeight,
+        row = 0,
+        column = 0
+    )
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
index 3a900332..092d8ce 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
@@ -211,7 +211,7 @@
         return LineIndex(currentLine)
     }
 
-    private fun spanOf(itemIndex: Int, maxSpan: Int) = with(itemProvider) {
+    fun spanOf(itemIndex: Int, maxSpan: Int) = with(itemProvider) {
         with(TvLazyGridItemSpanScopeImpl) {
             maxCurrentLineSpan = maxSpan
             maxLineSpan = slotsPerLine
@@ -225,6 +225,7 @@
         buckets.add(Bucket(0))
         lastLineIndex = 0
         lastLineStartItemIndex = 0
+        lastLineStartKnownSpan = 0
         cachedBucketIndex = -1
         cachedBucket.clear()
     }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
index 867f0c1..8c7e9e8 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
@@ -46,6 +46,14 @@
         }
     }
 
+    fun itemConstraints(itemIndex: ItemIndex): Constraints {
+        val span = spanLayoutProvider.spanOf(
+            itemIndex.value,
+            spanLayoutProvider.slotsPerLine
+        )
+        return childConstraints(0, span)
+    }
+
     /**
      * Used to subcompose items on lines of lazy grids. Composed placeables will be measured
      * with the correct constraints and wrapped into [LazyMeasuredLine].
@@ -89,7 +97,6 @@
 }
 
 // This interface allows to avoid autoboxing on index param
-@OptIn(ExperimentalFoundationApi::class)
 internal fun interface MeasuredLineFactory {
     fun createLine(
         index: LineIndex,
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt
index 27e5ca3..d2337aa 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt
@@ -17,56 +17,24 @@
 package androidx.tv.foundation.lazy.grid
 
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.ScrollableState
 import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.semantics.CollectionInfo
-import androidx.compose.ui.semantics.ScrollAxisRange
-import androidx.compose.ui.semantics.collectionInfo
-import androidx.compose.ui.semantics.horizontalScrollAxisRange
-import androidx.compose.ui.semantics.indexForKey
-import androidx.compose.ui.semantics.scrollBy
-import androidx.compose.ui.semantics.scrollToIndex
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.verticalScrollAxisRange
 import androidx.tv.foundation.lazy.layout.LazyLayoutSemanticState
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
 
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 internal fun rememberLazyGridSemanticState(
     state: TvLazyGridState,
-    itemProvider: LazyLayoutItemProvider,
     reverseScrolling: Boolean
 ): LazyLayoutSemanticState =
-    remember(state, itemProvider, reverseScrolling) {
+    remember(state, reverseScrolling) {
         object : LazyLayoutSemanticState {
-            override fun scrollAxisRange(): ScrollAxisRange =
-                ScrollAxisRange(
-                    value = {
-                        // This is a simple way of representing the current position without
-                        // needing any lazy items to be measured. It's good enough so far, because
-                        // screen-readers care mostly about whether scroll position changed or not
-                        // rather than the actual offset in pixels.
-                        state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
-                    },
-                    maxValue = {
-                        if (state.canScrollForward) {
-                            // If we can scroll further, we don't know the end yet,
-                            // but it's upper bounded by #items + 1
-                            itemProvider.itemCount + 1f
-                        } else {
-                            // If we can't scroll further, the current value is the max
-                            state.firstVisibleItemIndex +
-                                state.firstVisibleItemScrollOffset / 100_000f
-                        }
-                    },
-                    reverseScrolling = reverseScrolling
-                )
+            override val currentPosition: Float
+                get() = state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+            override val canScrollForward: Boolean
+                get() = state.canScrollForward
 
             override suspend fun animateScrollBy(delta: Float) {
                 state.animateScrollBy(delta)
@@ -81,111 +49,3 @@
                 CollectionInfo(rowCount = -1, columnCount = -1)
         }
     }
-
-@OptIn(ExperimentalFoundationApi::class)
-@Suppress("ComposableModifierFactory", "ModifierInspectorInfo")
-@Composable
-internal fun Modifier.lazyGridSemantics(
-    itemProvider: LazyGridItemProvider,
-    state: TvLazyGridState,
-    coroutineScope: CoroutineScope,
-    isVertical: Boolean,
-    reverseScrolling: Boolean,
-    userScrollEnabled: Boolean
-) = this.then(
-    remember(
-        itemProvider,
-        state,
-        isVertical,
-        reverseScrolling,
-        userScrollEnabled
-    ) {
-        val indexForKeyMapping: (Any) -> Int = { needle ->
-            val key = itemProvider::getKey
-            var result = -1
-            for (index in 0 until itemProvider.itemCount) {
-                if (key(index) == needle) {
-                    result = index
-                    break
-                }
-            }
-            result
-        }
-
-        val accessibilityScrollState = ScrollAxisRange(
-            value = {
-                // This is a simple way of representing the current position without
-                // needing any lazy items to be measured. It's good enough so far, because
-                // screen-readers care mostly about whether scroll position changed or not
-                // rather than the actual offset in pixels.
-                state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
-            },
-            maxValue = {
-                if (state.canScrollForward) {
-                    // If we can scroll further, we don't know the end yet,
-                    // but it's upper bounded by #items + 1
-                    itemProvider.itemCount + 1f
-                } else {
-                    // If we can't scroll further, the current value is the max
-                    state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
-                }
-            },
-            reverseScrolling = reverseScrolling
-        )
-
-        val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {
-            { x, y ->
-                val delta = if (isVertical) {
-                    y
-                } else {
-                    x
-                }
-                coroutineScope.launch {
-                    (state as ScrollableState).animateScrollBy(delta)
-                }
-                // TODO(aelias): is it important to return false if we know in advance we cannot scroll?
-                true
-            }
-        } else {
-            null
-        }
-
-        val scrollToIndexAction: ((Int) -> Boolean)? = if (userScrollEnabled) {
-            { index ->
-                require(index >= 0 && index < state.layoutInfo.totalItemsCount) {
-                    "Can't scroll to index $index, it is out of " +
-                        "bounds [0, ${state.layoutInfo.totalItemsCount})"
-                }
-                coroutineScope.launch {
-                    state.scrollToItem(index)
-                }
-                true
-            }
-        } else {
-            null
-        }
-
-        // TODO(popam): check if this is correct - it would be nice to provide correct columns here
-        val collectionInfo = CollectionInfo(rowCount = -1, columnCount = -1)
-
-        Modifier.semantics {
-            indexForKey(indexForKeyMapping)
-
-            if (isVertical) {
-                verticalScrollAxisRange = accessibilityScrollState
-            } else {
-                horizontalScrollAxisRange = accessibilityScrollState
-            }
-
-            if (scrollByAction != null) {
-                scrollBy(action = scrollByAction)
-            }
-
-            if (scrollToIndexAction != null) {
-                scrollToIndex(action = scrollToIndexAction)
-            }
-
-            this.collectionInfo = collectionInfo
-        }
-    }
-)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
index c7474100..3d9873f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
@@ -23,6 +23,7 @@
 import androidx.compose.foundation.gestures.ScrollableState
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
@@ -214,6 +215,11 @@
     private val animateScrollScope = LazyGridAnimateScrollScope(this)
 
     /**
+     * Stores currently pinned items which are always composed.
+     */
+    internal val pinnedItems = LazyLayoutPinnedItemList()
+
+    /**
      * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
      * pixels.
      *
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt
index c6ec353..aa12e0a 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt
@@ -23,8 +23,8 @@
 import androidx.compose.foundation.gestures.ScrollScope
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.dp
-import java.lang.Math.abs
 import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.abs
 
 private class ItemFoundInScroll(
     val itemOffset: Int,
@@ -120,7 +120,7 @@
             while (loop && itemCount > 0) {
                 val expectedDistance = expectedDistanceTo(index, scrollOffset)
                 val target = if (abs(expectedDistance) < targetDistancePx) {
-                    val absTargetPx = maxOf(kotlin.math.abs(expectedDistance), minDistancePx)
+                    val absTargetPx = maxOf(abs(expectedDistance), minDistancePx)
                     if (forward) absTargetPx else -absTargetPx
                 } else {
                     if (forward) targetDistancePx else -targetDistancePx
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt
new file mode 100644
index 0000000..fad4aa6
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.tv.foundation.lazy.layout
+
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.ui.semantics.CollectionInfo
+import androidx.tv.foundation.lazy.list.TvLazyListState
+
+internal fun LazyLayoutSemanticState(
+    state: TvLazyListState,
+    isVertical: Boolean
+): LazyLayoutSemanticState = object : LazyLayoutSemanticState {
+
+    override val currentPosition: Float
+        get() = state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+    override val canScrollForward: Boolean
+        get() = state.canScrollForward
+
+    override suspend fun animateScrollBy(delta: Float) {
+        state.animateScrollBy(delta)
+    }
+
+    override suspend fun scrollToItem(index: Int) {
+        state.scrollToItem(index)
+    }
+
+    override fun collectionInfo(): CollectionInfo =
+        if (isVertical) {
+            CollectionInfo(rowCount = -1, columnCount = 1)
+        } else {
+            CollectionInfo(rowCount = 1, columnCount = -1)
+        }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
index ddf0cfa..2d6def9 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
@@ -41,7 +41,8 @@
     itemProvider: LazyLayoutItemProvider,
     state: LazyLayoutSemanticState,
     orientation: Orientation,
-    userScrollEnabled: Boolean
+    userScrollEnabled: Boolean,
+    reverseScrolling: Boolean
 ): Modifier {
     val coroutineScope = rememberCoroutineScope()
     return this.then(
@@ -63,7 +64,26 @@
                 result
             }
 
-            val accessibilityScrollState = state.scrollAxisRange()
+            val accessibilityScrollState = ScrollAxisRange(
+                value = {
+                    // This is a simple way of representing the current position without
+                    // needing any lazy items to be measured. It's good enough so far, because
+                    // screen-readers care mostly about whether scroll position changed or not
+                    // rather than the actual offset in pixels.
+                    state.currentPosition
+                },
+                maxValue = {
+                    if (state.canScrollForward) {
+                        // If we can scroll further, we don't know the end yet,
+                        // but it's upper bounded by #items + 1
+                        itemProvider.itemCount + 1f
+                    } else {
+                        // If we can't scroll further, the current value is the max
+                        state.currentPosition
+                    }
+                },
+                reverseScrolling = reverseScrolling
+            )
 
             val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {
                 { x, y ->
@@ -123,7 +143,8 @@
 }
 
 internal interface LazyLayoutSemanticState {
-    fun scrollAxisRange(): ScrollAxisRange
+    val currentPosition: Float
+    val canScrollForward: Boolean
     fun collectionInfo(): CollectionInfo
     suspend fun animateScrollBy(delta: Float)
     suspend fun scrollToItem(index: Int)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
index 2044acd..eb05a8e 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
@@ -78,8 +78,7 @@
     content: TvLazyListScope.() -> Unit
 ) {
     val itemProvider = rememberLazyListItemProvider(state, content)
-    val semanticState =
-        rememberLazyListSemanticState(state, itemProvider, reverseLayout, isVertical)
+    val semanticState = rememberLazyListSemanticState(state, isVertical)
     val beyondBoundsInfo = remember { LazyListBeyondBoundsInfo() }
     val scope = rememberCoroutineScope()
     val placementAnimator = remember(state, isVertical) {
@@ -114,7 +113,8 @@
                 itemProvider = itemProvider,
                 state = semanticState,
                 orientation = orientation,
-                userScrollEnabled = userScrollEnabled
+                userScrollEnabled = userScrollEnabled,
+                reverseScrolling = reverseLayout
             )
             .clipScrollableContainer(orientation)
             .lazyListBeyondBoundsModifier(state, beyondBoundsInfo, reverseLayout, orientation)
@@ -299,7 +299,8 @@
 
         measureLazyList(
             itemsCount = itemsCount,
-            itemProvider = measuredItemProvider,
+            itemProvider = itemProvider,
+            measuredItemProvider = measuredItemProvider,
             mainAxisAvailableSize = mainAxisAvailableSize,
             beforeContentPadding = beforeContentPadding,
             afterContentPadding = afterContentPadding,
@@ -326,6 +327,6 @@
                     placement
                 )
             }
-        ).also { state.applyMeasureResult(it) }
+        ).also(state::applyMeasureResult)
     }
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
index 0bfa56d..3a406d3 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.lazy.layout.DelegatingLazyLayoutItemProvider
 import androidx.compose.foundation.lazy.layout.IntervalList
 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem
 import androidx.compose.foundation.lazy.layout.rememberLazyNearestItemsRangeState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.derivedStateOf
@@ -81,8 +82,13 @@
         intervals = intervals,
         nearestItemsRange = nearestItemsRange,
         itemContent = { interval, index ->
-            LazyListPinnableContainerProvider(state, index) {
-                interval.value.item.invoke(itemScope, index - interval.startIndex)
+            val localIndex = index - interval.startIndex
+            LazyLayoutPinnableItem(
+                key = interval.value.key?.invoke(localIndex),
+                index = index,
+                pinnedItemList = state.pinnedItems
+            ) {
+                interval.value.item.invoke(itemScope, localIndex)
             }
         }
     )
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
index aa938b9..080c4b8 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
@@ -16,8 +16,10 @@
 
 package androidx.tv.foundation.lazy.list
 
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
@@ -37,9 +39,12 @@
  * Measures and calculates the positions for the requested items. The result is produced
  * as a [LazyListMeasureResult] which contains all the calculations.
  */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
 internal fun measureLazyList(
     itemsCount: Int,
-    itemProvider: LazyMeasuredItemProvider,
+    itemProvider: LazyListItemProvider,
+    measuredItemProvider: LazyMeasuredItemProvider,
     mainAxisAvailableSize: Int,
     beforeContentPadding: Int,
     afterContentPadding: Int,
@@ -57,7 +62,7 @@
     placementAnimator: LazyListItemPlacementAnimator,
     beyondBoundsInfo: LazyListBeyondBoundsInfo,
     beyondBoundsItemCount: Int,
-    pinnedItems: List<LazyListPinnedItem>,
+    pinnedItems: LazyLayoutPinnedItemList,
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): LazyListMeasureResult {
     require(beforeContentPadding >= 0)
@@ -122,7 +127,7 @@
         // firstItemScrollOffset
         while (currentFirstItemScrollOffset < 0 && currentFirstItemIndex > DataIndex(0)) {
             val previous = DataIndex(currentFirstItemIndex.value - 1)
-            val measuredItem = itemProvider.getAndMeasure(previous)
+            val measuredItem = measuredItemProvider.getAndMeasure(previous)
             visibleItems.add(0, measuredItem)
             maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
             currentFirstItemScrollOffset += measuredItem.sizeWithSpacings
@@ -157,7 +162,7 @@
                 currentMainAxisOffset <= 0 || // filling beforeContentPadding area
                 visibleItems.isEmpty())
         ) {
-            val measuredItem = itemProvider.getAndMeasure(index)
+            val measuredItem = measuredItemProvider.getAndMeasure(index)
             currentMainAxisOffset += measuredItem.sizeWithSpacings
 
             if (currentMainAxisOffset <= minOffset && index.value != itemsCount - 1) {
@@ -182,7 +187,7 @@
                 currentFirstItemIndex > DataIndex(0)
             ) {
                 val previousIndex = DataIndex(currentFirstItemIndex.value - 1)
-                val measuredItem = itemProvider.getAndMeasure(previousIndex)
+                val measuredItem = measuredItemProvider.getAndMeasure(previousIndex)
                 visibleItems.add(0, measuredItem)
                 maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
                 currentFirstItemScrollOffset += measuredItem.sizeWithSpacings
@@ -233,6 +238,7 @@
         val extraItemsBefore = createItemsBeforeList(
             beyondBoundsInfo = beyondBoundsInfo,
             currentFirstItemIndex = currentFirstItemIndex,
+            measuredItemProvider = measuredItemProvider,
             itemProvider = itemProvider,
             itemsCount = itemsCount,
             beyondBoundsItemCount = beyondBoundsItemCount,
@@ -248,6 +254,7 @@
         val extraItemsAfter = createItemsAfterList(
             beyondBoundsInfo = beyondBoundsInfo,
             visibleItems = visibleItems,
+            measuredItemProvider = measuredItemProvider,
             itemProvider = itemProvider,
             itemsCount = itemsCount,
             beyondBoundsItemCount = beyondBoundsItemCount,
@@ -289,13 +296,13 @@
             layoutWidth = layoutWidth,
             layoutHeight = layoutHeight,
             positionedItems = positionedItems,
-            itemProvider = itemProvider
+            itemProvider = measuredItemProvider
         )
 
         val headerItem = if (headerIndexes.isNotEmpty()) {
             findOrComposeLazyListHeader(
                 composedVisibleItems = positionedItems,
-                itemProvider = itemProvider,
+                itemProvider = measuredItemProvider,
                 headerIndexes = headerIndexes,
                 beforeContentPadding = beforeContentPadding,
                 layoutWidth = layoutWidth,
@@ -334,13 +341,16 @@
     }
 }
 
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
 private fun createItemsAfterList(
     beyondBoundsInfo: LazyListBeyondBoundsInfo,
     visibleItems: MutableList<LazyMeasuredItem>,
-    itemProvider: LazyMeasuredItemProvider,
+    measuredItemProvider: LazyMeasuredItemProvider,
+    itemProvider: LazyListItemProvider,
     itemsCount: Int,
     beyondBoundsItemCount: Int,
-    pinnedItems: List<LazyListPinnedItem>
+    pinnedItems: LazyLayoutPinnedItemList
 ): List<LazyMeasuredItem> {
     fun LazyListBeyondBoundsInfo.endIndex() = min(end, itemsCount - 1)
 
@@ -351,7 +361,7 @@
     fun addItem(index: Int) {
         if (list == null) list = mutableListOf()
         requireNotNull(list).add(
-            itemProvider.getAndMeasure(DataIndex(index))
+            measuredItemProvider.getAndMeasure(DataIndex(index))
         )
     }
 
@@ -365,22 +375,26 @@
         addItem(i)
     }
 
-    pinnedItems.fastForEach {
-        if (it.index > end && it.index < itemsCount) {
-            addItem(it.index)
+    pinnedItems.fastForEach { item ->
+        val index = itemProvider.findIndexByKey(item.key, item.index)
+        if (index > end && index < itemsCount) {
+            addItem(index)
         }
     }
 
     return list ?: emptyList()
 }
 
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
 private fun createItemsBeforeList(
     beyondBoundsInfo: LazyListBeyondBoundsInfo,
     currentFirstItemIndex: DataIndex,
-    itemProvider: LazyMeasuredItemProvider,
+    measuredItemProvider: LazyMeasuredItemProvider,
+    itemProvider: LazyListItemProvider,
     itemsCount: Int,
     beyondBoundsItemCount: Int,
-    pinnedItems: List<LazyListPinnedItem>
+    pinnedItems: LazyLayoutPinnedItemList
 ): List<LazyMeasuredItem> {
     fun LazyListBeyondBoundsInfo.startIndex() = min(start, itemsCount - 1)
 
@@ -391,7 +405,7 @@
     fun addItem(index: Int) {
         if (list == null) list = mutableListOf()
         requireNotNull(list).add(
-            itemProvider.getAndMeasure(DataIndex(index))
+            measuredItemProvider.getAndMeasure(DataIndex(index))
         )
     }
 
@@ -405,9 +419,10 @@
         addItem(i)
     }
 
-    pinnedItems.fastForEach {
-        if (it.index < start) {
-            addItem(it.index)
+    pinnedItems.fastForEach { item ->
+        val index = itemProvider.findIndexByKey(item.key, item.index)
+        if (index < start) {
+            addItem(index)
         }
     }
 
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListPinnableContainerProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListPinnableContainerProvider.kt
deleted file mode 100644
index 4ae5108..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListPinnableContainerProvider.kt
+++ /dev/null
@@ -1,111 +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.tv.foundation.lazy.list
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.ui.layout.LocalPinnableContainer
-import androidx.compose.ui.layout.PinnableContainer
-
-internal interface LazyListPinnedItem {
-    val index: Int
-}
-
-@Composable
-internal fun LazyListPinnableContainerProvider(
-    state: TvLazyListState,
-    index: Int,
-    content: @Composable () -> Unit
-) {
-    val pinnableItem = remember(state) { LazyListPinnableItem(state) }
-    pinnableItem.index = index
-    pinnableItem.parentPinnableContainer = LocalPinnableContainer.current
-    DisposableEffect(pinnableItem) { onDispose { pinnableItem.onDisposed() } }
-    CompositionLocalProvider(
-        LocalPinnableContainer provides pinnableItem, content = content
-    )
-}
-
-private class LazyListPinnableItem(
-    private val state: TvLazyListState,
-) : PinnableContainer, PinnableContainer.PinnedHandle, LazyListPinnedItem {
-    /**
-     * Current index associated with this item.
-     */
-    override var index by mutableStateOf(-1)
-
-    /**
-     * It is a valid use case when users of this class call [pin] multiple times individually,
-     * so we want to do the unpinning only when all of the users called [release].
-     */
-    private var pinsCount by mutableStateOf(0)
-
-    /**
-     * Handle associated with the current [parentPinnableContainer].
-     */
-    private var parentHandle by mutableStateOf<PinnableContainer.PinnedHandle?>(null)
-
-    /**
-     * Current parent [PinnableContainer].
-     * Note that we should correctly re-pin if we pinned the previous container.
-     */
-    private var _parentPinnableContainer by mutableStateOf<PinnableContainer?>(null)
-    var parentPinnableContainer: PinnableContainer? get() = _parentPinnableContainer
-        set(value) {
-            Snapshot.withoutReadObservation {
-                val previous = _parentPinnableContainer
-                if (value !== previous) {
-                    _parentPinnableContainer = value
-                    if (pinsCount > 0) {
-                        parentHandle?.release()
-                        parentHandle = value?.pin()
-                    }
-                }
-            }
-        }
-
-    override fun pin(): PinnableContainer.PinnedHandle {
-        if (pinsCount == 0) {
-            state.pinnedItems.add(this)
-            parentHandle = parentPinnableContainer?.pin()
-        }
-        pinsCount++
-        return this
-    }
-
-    override fun release() {
-        check(pinsCount > 0) { "Release should only be called once" }
-        pinsCount--
-        if (pinsCount == 0) {
-            state.pinnedItems.remove(this)
-            parentHandle?.release()
-            parentHandle = null
-        }
-    }
-
-    fun onDisposed() {
-        repeat(pinsCount) {
-            release()
-        }
-    }
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
index aae8340..f5680d1 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
@@ -23,11 +23,11 @@
 import androidx.compose.foundation.gestures.ScrollableState
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.Saver
 import androidx.compose.runtime.saveable.listSaver
@@ -218,9 +218,9 @@
     internal var premeasureConstraints by mutableStateOf(Constraints())
 
     /**
-     * List of extra items to compose during the measure pass.
+     * Stores currently pinned items which are always composed.
      */
-    internal val pinnedItems = mutableStateListOf<LazyListPinnedItem>()
+    internal val pinnedItems = LazyLayoutPinnedItemList()
 
     /**
      * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt
index 560f0f1..067b94a 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt
@@ -17,12 +17,8 @@
 package androidx.tv.foundation.lazy.list
 
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
-import androidx.compose.ui.semantics.CollectionInfo
-import androidx.compose.ui.semantics.ScrollAxisRange
 import androidx.tv.foundation.lazy.layout.LazyLayoutSemanticState
 
 // TODO (b/233188423): Address IllegalExperimentalApiUsage before moving to beta
@@ -31,48 +27,9 @@
 @Composable
 internal fun rememberLazyListSemanticState(
     state: TvLazyListState,
-    itemProvider: LazyLayoutItemProvider,
-    reverseScrolling: Boolean,
     isVertical: Boolean
-): LazyLayoutSemanticState =
-    remember(state, itemProvider, reverseScrolling, isVertical) {
-        object : LazyLayoutSemanticState {
-            override fun scrollAxisRange(): ScrollAxisRange =
-                ScrollAxisRange(
-                    value = {
-                        // This is a simple way of representing the current position without
-                        // needing any lazy items to be measured. It's good enough so far, because
-                        // screen-readers care mostly about whether scroll position changed or not
-                        // rather than the actual offset in pixels.
-                        state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
-                    },
-                    maxValue = {
-                        if (state.canScrollForward) {
-                            // If we can scroll further, we don't know the end yet,
-                            // but it's upper bounded by #items + 1
-                            itemProvider.itemCount + 1f
-                        } else {
-                            // If we can't scroll further, the current value is the max
-                            state.firstVisibleItemIndex +
-                                state.firstVisibleItemScrollOffset / 100_000f
-                        }
-                    },
-                    reverseScrolling = reverseScrolling
-                )
-
-            override suspend fun animateScrollBy(delta: Float) {
-                state.animateScrollBy(delta)
-            }
-
-            override suspend fun scrollToItem(index: Int) {
-                state.scrollToItem(index)
-            }
-
-            override fun collectionInfo(): CollectionInfo =
-                if (isVertical) {
-                    CollectionInfo(rowCount = -1, columnCount = 1)
-                } else {
-                    CollectionInfo(rowCount = 1, columnCount = -1)
-                }
-        }
-    }
\ No newline at end of file
+): LazyLayoutSemanticState {
+    return remember(state, isVertical) {
+        LazyLayoutSemanticState(state = state, isVertical = isVertical)
+    }
+}
\ No newline at end of file