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