diff options
68 files changed, 1889 insertions, 1211 deletions
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridBeyondBoundsTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridBeyondBoundsTest.kt index f0315addc13..107ee850b21 100644 --- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridBeyondBoundsTest.kt +++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridBeyondBoundsTest.kt @@ -18,6 +18,7 @@ package androidx.compose.foundation.lazy.grid import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.list.TrackPlacedElement import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,7 +33,6 @@ import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.B import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.modifier.modifierLocalConsumer import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.ComposeContentTestRule @@ -97,7 +97,7 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -117,7 +117,7 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -137,7 +137,7 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -191,13 +191,13 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box(Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -207,11 +207,10 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -224,7 +223,6 @@ class LazyGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -251,13 +249,13 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box(Modifier .size(itemSizeDp) - .onPlaced { placedItems += 11 } + .trackPlaced(11) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -267,11 +265,10 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp) - .onPlaced { placedItems += index + 12 } + .trackPlaced(index + 12) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -284,7 +281,6 @@ class LazyGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(10, 11, 12, 13, 14, 15, 16) assertThat(visibleItems).containsExactly(10, 11, 12, 13, 14, 15) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -307,14 +303,14 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -324,17 +320,15 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (--extraItemCount > 0) { - placedItems.clear() // Return null to continue the search. null } else { @@ -346,7 +340,6 @@ class LazyGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to stop the search. true } @@ -368,7 +361,7 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { @@ -378,26 +371,22 @@ class LazyGridBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (hasMoreContent) { - placedItems.clear() // Just return null so that we keep adding more items till we reach the end. null } else { @@ -409,7 +398,6 @@ class LazyGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9, 10) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to end the search. true } @@ -431,14 +419,14 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -448,14 +436,13 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } rule.runOnIdle { assertThat(placedItems).containsExactly(5, 6, 7) assertThat(visibleItems).containsExactly(5, 6, 7) - placedItems.clear() } // Act. @@ -477,7 +464,6 @@ class LazyGridBeyondBoundsTest(param: Param) { } } } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -509,9 +495,7 @@ class LazyGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index - } + .trackPlaced(index) ) } item { @@ -521,20 +505,17 @@ class LazyGridBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. var count = 0 @@ -542,7 +523,6 @@ class LazyGridBeyondBoundsTest(param: Param) { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { // Assert that we don't keep iterating when there is no ending condition. assertThat(count++).isLessThan(lazyGridState.layoutInfo.totalItemsCount) - placedItems.clear() // Always return null to continue the search. null } @@ -636,4 +616,7 @@ class LazyGridBeyondBoundsTest(param: Param) { private fun unsupportedDirection(): Nothing = error( "Lazy list does not support beyond bounds layout for the specified direction" ) + + private fun Modifier.trackPlaced(index: Int): Modifier = + this then TrackPlacedElement(placedItems, index) } diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsAndExtraItemsTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsAndExtraItemsTest.kt index 559f3bb4f3e..3be7e1910ac 100644 --- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsAndExtraItemsTest.kt +++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsAndExtraItemsTest.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.B import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.modifier.modifierLocalConsumer import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection @@ -51,12 +50,12 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : private val beyondBoundsLayoutDirection = config.beyondBoundsLayoutDirection private val reverseLayout = config.reverseLayout private val layoutDirection = config.layoutDirection + private val placedItems = mutableSetOf<Int>() @OptIn(ExperimentalComposeUiApi::class) @Test fun verifyItemsArePlacedBeforeBeyondBoundsItems_oneBeyondBoundItem() { // Arrange - val placedItems = mutableSetOf<Int>() var beyondBoundsLayout: BeyondBoundsLayout? = null val lazyListState = LazyListState() rule.setContent { @@ -71,14 +70,14 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : Box( Modifier .size(10.dp) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.dp) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -88,14 +87,13 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : Box( Modifier .size(10.dp) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } } } rule.runOnIdle { runBlocking { lazyListState.scrollToItem(5) } } - rule.runOnIdle { placedItems.clear() } // Act rule.runOnUiThread { @@ -107,7 +105,6 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : assertThat(placedItems).containsAtLeast(4, 5, 6, 7, 8, 9) } assertThat(lazyListState.visibleItems).containsAtLeast(5, 6, 7) - placedItems.clear() true } } @@ -123,7 +120,6 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : @Test fun verifyItemsArePlacedBeforeBeyondBoundsItems_twoBeyondBoundItem() { // Arrange - val placedItems = mutableSetOf<Int>() var beyondBoundsLayout: BeyondBoundsLayout? = null val lazyListState = LazyListState() var extraItemCount = 2 @@ -139,14 +135,14 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : Box( Modifier .size(10.dp) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.dp) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -156,20 +152,18 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : Box( Modifier .size(10.dp) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } } } rule.runOnIdle { runBlocking { lazyListState.scrollToItem(5) } } - rule.runOnIdle { placedItems.clear() } // Act rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (--extraItemCount > 0) { - placedItems.clear() // Return null to continue the search. null } else { @@ -180,7 +174,6 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : assertThat(placedItems).containsAtLeast(4, 5, 6, 7, 8, 9, 10) } assertThat(lazyListState.visibleItems).containsAtLeast(5, 6, 7) - placedItems.clear() true } } @@ -249,4 +242,7 @@ class LazyListBeyondBoundsAndExtraItemsTest(val config: Config) : Before -> true else -> error("Unsupported BeyondBoundsDirection") } + + private fun Modifier.trackPlaced(index: Int): Modifier = + this then TrackPlacedElement(placedItems, index) }
\ No newline at end of file diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsTest.kt index d431ea40844..4e0a1edc243 100644 --- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsTest.kt +++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListBeyondBoundsTest.kt @@ -36,9 +36,12 @@ import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.B import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Below import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule @@ -102,7 +105,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -122,7 +125,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -142,7 +145,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -196,13 +199,13 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box(Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -212,11 +215,10 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -229,7 +231,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -252,14 +253,14 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -269,17 +270,15 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (--extraItemCount > 0) { - placedItems.clear() // Return null to continue the search. null } else { @@ -291,7 +290,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to stop the search. true } @@ -313,7 +311,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { @@ -323,26 +321,22 @@ class LazyListBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (hasMoreContent) { - placedItems.clear() // Just return null so that we keep adding more items till we reach the end. null } else { @@ -354,7 +348,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9, 10) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to end the search. true } @@ -376,14 +369,14 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -393,14 +386,13 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } rule.runOnIdle { assertThat(placedItems).containsExactly(5, 6, 7) assertThat(visibleItems).containsExactly(5, 6, 7) - placedItems.clear() } // Act. @@ -422,7 +414,6 @@ class LazyListBeyondBoundsTest(param: Param) { } } } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -454,9 +445,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index - } + .trackPlaced(index) ) } item { @@ -466,20 +455,17 @@ class LazyListBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. var count = 0 @@ -487,7 +473,6 @@ class LazyListBeyondBoundsTest(param: Param) { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { // Assert that we don't keep iterating when there is no ending condition. assertThat(count++).isLessThan(lazyListState.layoutInfo.totalItemsCount) - placedItems.clear() // Always return null to continue the search. null } @@ -576,4 +561,37 @@ class LazyListBeyondBoundsTest(param: Param) { private fun unsupportedDirection(): Nothing = error( "Lazy list does not support beyond bounds layout for the specified direction" ) + + private fun Modifier.trackPlaced(index: Int): Modifier = + this then TrackPlacedElement(placedItems, index) +} + +internal data class TrackPlacedElement( + var placedItems: MutableSet<Int>, + var index: Int +) : ModifierNodeElement<TrackPlacedNode>() { + override fun create() = TrackPlacedNode(placedItems, index) + + override fun update(node: TrackPlacedNode) { + node.placedItems = placedItems + node.index = index + } + + override fun InspectorInfo.inspectableProperties() { + name = "trackPlaced" + properties["index"] = index + } +} + +internal class TrackPlacedNode( + var placedItems: MutableSet<Int>, + var index: Int +) : LayoutAwareModifierNode, Modifier.Node() { + override fun onPlaced(coordinates: LayoutCoordinates) { + placedItems += index + } + + override fun onDetach() { + placedItems -= index + } } diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridBeyondBoundsTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridBeyondBoundsTest.kt index 1b82ba82f6a..116e9ae7a50 100644 --- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridBeyondBoundsTest.kt +++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridBeyondBoundsTest.kt @@ -18,6 +18,7 @@ package androidx.compose.foundation.lazy.staggeredgrid import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.list.TrackPlacedElement import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,7 +33,6 @@ import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.B import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.modifier.modifierLocalConsumer import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.ComposeContentTestRule @@ -97,7 +97,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -117,7 +117,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -137,7 +137,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -191,13 +191,13 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box(Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -207,11 +207,10 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -224,7 +223,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -251,13 +249,13 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box(Modifier .size(itemSizeDp) - .onPlaced { placedItems += 11 } + .trackPlaced(11) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -267,11 +265,10 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp) - .onPlaced { placedItems += index + 12 } + .trackPlaced(index + 12) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -284,7 +281,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(10, 11, 12, 13, 14, 15, 16) assertThat(visibleItems).containsExactly(10, 11, 12, 13, 14, 15) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -319,13 +315,13 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp * if (index % 2 == 0) 2f else 1f) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item(span = StaggeredGridItemSpan.FullLine) { Box(Modifier .size(itemSizeDp) - .onPlaced { placedItems += 4 } + .trackPlaced(4) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -335,11 +331,10 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(itemSizeDp * if (index % 2 == 0) 2f else 1f) - .onPlaced { placedItems += index + 5 } + .trackPlaced(index + 5) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -352,7 +347,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(4, 5, 6, 7, 8) assertThat(visibleItems).containsExactly(4, 5, 6, 7) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -375,14 +369,14 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -392,17 +386,15 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (--extraItemCount > 0) { - placedItems.clear() // Return null to continue the search. null } else { @@ -414,7 +406,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to stop the search. true } @@ -436,7 +427,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { @@ -446,26 +437,22 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (hasMoreContent) { - placedItems.clear() // Just return null so that we keep adding more items till we reach the end. null } else { @@ -477,7 +464,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9, 10) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to end the search. true } @@ -499,14 +485,14 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -516,14 +502,13 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } rule.runOnIdle { assertThat(placedItems).containsExactly(5, 6, 7) assertThat(visibleItems).containsExactly(5, 6, 7) - placedItems.clear() } // Act. @@ -545,7 +530,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { } } } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -577,9 +561,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index - } + .trackPlaced(index) ) } item { @@ -589,20 +571,17 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. var count = 0 @@ -610,7 +589,6 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { // Assert that we don't keep iterating when there is no ending condition. assertThat(count++).isLessThan(lazyStaggeredGridState.layoutInfo.totalItemsCount) - placedItems.clear() // Always return null to continue the search. null } @@ -704,4 +682,7 @@ class LazyStaggeredGridBeyondBoundsTest(param: Param) { private fun unsupportedDirection(): Nothing = error( "Lazy list does not support beyond bounds layout for the specified direction" ) + + private fun Modifier.trackPlaced(index: Int): Modifier = + this then TrackPlacedElement(placedItems, index) } diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetcher.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetcher.android.kt index db7cb63db0f..eddfb4491c6 100644 --- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetcher.android.kt +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetcher.android.kt @@ -145,7 +145,8 @@ internal class LazyLayoutPrefetcher( // a next frame callback in which we will post the message in the handler again. if (enoughTimeLeft(beforeTimeNs, nextFrameNs, timeTracker.compositionTimeNs)) { val key = itemProvider.getKey(request.index) - val content = itemContentFactory.getContent(request.index, key) + val contentType = itemProvider.getContentType(request.index) + val content = itemContentFactory.getContent(request.index, key, contentType) timeTracker.trackComposition { request.precomposeHandle = subcomposeLayoutState.precompose(key, content) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt index 05949c78d1d..27b1d40a6e6 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset @@ -75,12 +76,12 @@ internal fun LazyList( /** The content of the list */ content: LazyListScope.() -> Unit ) { - val itemProvider = rememberLazyListItemProvider(state, content) + val itemProviderLambda = rememberLazyListItemProviderLambda(state, content) val semanticState = rememberLazyListSemanticState(state, isVertical) val measurePolicy = rememberLazyListMeasurePolicy( - itemProvider, + itemProviderLambda, state, contentPadding, reverseLayout, @@ -92,7 +93,7 @@ internal fun LazyList( verticalArrangement ) - ScrollPositionUpdater(itemProvider, state) + ScrollPositionUpdater(itemProviderLambda, state) val overscrollEffect = ScrollableDefaults.overscrollEffect() val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal @@ -101,7 +102,7 @@ internal fun LazyList( .then(state.remeasurementModifier) .then(state.awaitLayoutModifier) .lazyLayoutSemantics( - itemProvider = itemProvider, + itemProviderLambda = itemProviderLambda, state = semanticState, orientation = orientation, userScrollEnabled = userScrollEnabled, @@ -130,7 +131,7 @@ internal fun LazyList( ), prefetchState = state.prefetchState, measurePolicy = measurePolicy, - itemProvider = itemProvider + itemProvider = itemProviderLambda ) } @@ -138,9 +139,10 @@ internal fun LazyList( @ExperimentalFoundationApi @Composable private fun ScrollPositionUpdater( - itemProvider: LazyListItemProvider, + itemProviderLambda: () -> LazyListItemProvider, state: LazyListState ) { + val itemProvider = itemProviderLambda() if (itemProvider.itemCount > 0) { state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) } @@ -150,7 +152,7 @@ private fun ScrollPositionUpdater( @Composable private fun rememberLazyListMeasurePolicy( /** Items provider of the list. */ - itemProvider: LazyListItemProvider, + itemProviderLambda: () -> LazyListItemProvider, /** The state of the list. */ state: LazyListState, /** The inner padding to be added for the whole content(nor for each individual item) */ @@ -216,11 +218,10 @@ private fun rememberLazyListMeasurePolicy( val contentConstraints = containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - // Update the state's cached Density state.density = this + val itemProvider = itemProviderLambda() // this will update the scope used by the item composables itemProvider.itemScope.setMaxSize( width = contentConstraints.maxWidth, @@ -254,37 +255,46 @@ private fun rememberLazyListMeasurePolicy( ) } - val measuredItemProvider = LazyListMeasuredItemProvider( + val measuredItemProvider = object : LazyListMeasuredItemProvider( contentConstraints, isVertical, itemProvider, this - ) { index, key, contentType, placeables -> - // we add spaceBetweenItems as an extra spacing for all items apart from the last one so - // the lazy list measuring logic will take it into account. - val spacing = if (index == itemsCount - 1) 0 else spaceBetweenItems - LazyListMeasuredItem( - index = index, - placeables = placeables, - isVertical = isVertical, - horizontalAlignment = horizontalAlignment, - verticalAlignment = verticalAlignment, - layoutDirection = layoutDirection, - reverseLayout = reverseLayout, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spacing = spacing, - visualOffset = visualItemOffset, - key = key, - contentType = contentType - ) + ) { + override fun createItem( + index: Int, + key: Any, + contentType: Any?, + placeables: List<Placeable> + ): LazyListMeasuredItem { + // we add spaceBetweenItems as an extra spacing for all items apart from the last one so + // the lazy list measuring logic will take it into account. + val spacing = if (index == itemsCount - 1) 0 else spaceBetweenItems + return LazyListMeasuredItem( + index = index, + placeables = placeables, + isVertical = isVertical, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + layoutDirection = layoutDirection, + reverseLayout = reverseLayout, + beforeContentPadding = beforeContentPadding, + afterContentPadding = afterContentPadding, + spacing = spacing, + visualOffset = visualItemOffset, + key = key, + contentType = contentType + ) + } } state.premeasureConstraints = measuredItemProvider.childConstraints val firstVisibleItemIndex: Int val firstVisibleScrollOffset: Int Snapshot.withoutReadObservation { - firstVisibleItemIndex = state.firstVisibleItemIndex + firstVisibleItemIndex = state.updateScrollPositionIfTheFirstItemWasMoved( + itemProvider, state.firstVisibleItemIndex + ) firstVisibleScrollOffset = state.firstVisibleItemScrollOffset } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt index 978d3a81489..8cf55e5f531 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt @@ -28,13 +28,13 @@ import androidx.compose.ui.util.fastForEachIndexed * @param beforeContentPadding the padding before the first item in the list */ internal fun findOrComposeLazyListHeader( - composedVisibleItems: MutableList<LazyListPositionedItem>, + composedVisibleItems: MutableList<LazyListMeasuredItem>, itemProvider: LazyListMeasuredItemProvider, headerIndexes: List<Int>, beforeContentPadding: Int, layoutWidth: Int, layoutHeight: Int, -): LazyListPositionedItem? { +): LazyListMeasuredItem? { var currentHeaderOffset: Int = Int.MIN_VALUE var nextHeaderOffset: Int = Int.MIN_VALUE @@ -83,11 +83,11 @@ internal fun findOrComposeLazyListHeader( headerOffset = minOf(headerOffset, nextHeaderOffset - measuredHeaderItem.size) } - return measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight).also { - if (indexInComposedVisibleItems != -1) { - composedVisibleItems[indexInComposedVisibleItems] = it - } else { - composedVisibleItems.add(0, it) - } + measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight) + if (indexInComposedVisibleItems != -1) { + composedVisibleItems[indexInComposedVisibleItems] = measuredHeaderItem + } else { + composedVisibleItems.add(0, measuredHeaderItem) } + return measuredHeaderItem } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt index 497ba6d4cdc..ec0fb82e7e4 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt @@ -40,8 +40,8 @@ internal class LazyListItemPlacementAnimator { // stored to not allocate it every pass. private val movingAwayKeys = LinkedHashSet<Any>() - private val movingInFromStartBound = mutableListOf<LazyListPositionedItem>() - private val movingInFromEndBound = mutableListOf<LazyListPositionedItem>() + private val movingInFromStartBound = mutableListOf<LazyListMeasuredItem>() + private val movingInFromEndBound = mutableListOf<LazyListMeasuredItem>() private val movingAwayToStartBound = mutableListOf<LazyListMeasuredItem>() private val movingAwayToEndBound = mutableListOf<LazyListMeasuredItem>() @@ -54,7 +54,7 @@ internal class LazyListItemPlacementAnimator { consumedScroll: Int, layoutWidth: Int, layoutHeight: Int, - positionedItems: MutableList<LazyListPositionedItem>, + positionedItems: MutableList<LazyListMeasuredItem>, itemProvider: LazyListMeasuredItemProvider, isVertical: Boolean ) { @@ -167,9 +167,9 @@ internal class LazyListItemPlacementAnimator { accumulatedOffset += item.size val mainAxisOffset = 0 - accumulatedOffset - val positionedItem = item.position(mainAxisOffset, layoutWidth, layoutHeight) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + item.position(mainAxisOffset, layoutWidth, layoutHeight) + positionedItems.add(item) + startAnimationsIfNeeded(item) } accumulatedOffset = 0 movingAwayToEndBound.sortBy { keyIndexMap.getIndex(it.key) } @@ -177,9 +177,9 @@ internal class LazyListItemPlacementAnimator { val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset accumulatedOffset += item.size - val positionedItem = item.position(mainAxisOffset, layoutWidth, layoutHeight) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + item.position(mainAxisOffset, layoutWidth, layoutHeight) + positionedItems.add(item) + startAnimationsIfNeeded(item) } movingInFromStartBound.clear() @@ -200,7 +200,7 @@ internal class LazyListItemPlacementAnimator { } private fun initializeNode( - item: LazyListPositionedItem, + item: LazyListMeasuredItem, mainAxisOffset: Int ) { val firstPlaceableOffset = item.getOffset(0) @@ -219,7 +219,7 @@ internal class LazyListItemPlacementAnimator { } } - private fun startAnimationsIfNeeded(item: LazyListPositionedItem) { + private fun startAnimationsIfNeeded(item: LazyListMeasuredItem) { item.forEachNode { placeableIndex, node -> val newTarget = item.getOffset(placeableIndex) val currentTarget = node.rawOffset @@ -234,13 +234,13 @@ internal class LazyListItemPlacementAnimator { private val Any?.node get() = this as? LazyLayoutAnimateItemModifierNode - private val LazyListPositionedItem.hasAnimations: Boolean + private val LazyListMeasuredItem.hasAnimations: Boolean get() { forEachNode { _, _ -> return true } return false } - private inline fun LazyListPositionedItem.forEachNode( + private inline fun LazyListMeasuredItem.forEachNode( block: (placeableIndex: Int, node: LazyLayoutAnimateItemModifierNode) -> Unit ) { repeat(placeablesCount) { index -> diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt index 1a5eb66a97c..ae95eb22156 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt @@ -20,10 +20,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem -import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMapState +import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMap import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -39,64 +38,68 @@ internal interface LazyListItemProvider : LazyLayoutItemProvider { @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun rememberLazyListItemProvider( +internal fun rememberLazyListItemProviderLambda( state: LazyListState, content: LazyListScope.() -> Unit -): LazyListItemProvider { +): () -> LazyListItemProvider { val latestContent = rememberUpdatedState(content) - return remember(state, latestContent) { - LazyListItemProviderImpl( - state = state, - latestContent = { latestContent.value }, - itemScope = LazyItemScopeImpl() - ) + return remember(state) { + val scope = LazyItemScopeImpl() + val intervalContentState = derivedStateOf(referentialEqualityPolicy()) { + LazyListIntervalContent(latestContent.value) + } + val itemProviderState = derivedStateOf(referentialEqualityPolicy()) { + val intervalContent = intervalContentState.value + val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent) + LazyListItemProviderImpl( + state = state, + intervalContent = intervalContent, + itemScope = scope, + keyIndexMap = map + ) + } + itemProviderState::value } } @ExperimentalFoundationApi private class LazyListItemProviderImpl constructor( private val state: LazyListState, - private val latestContent: () -> (LazyListScope.() -> Unit), - override val itemScope: LazyItemScopeImpl + private val intervalContent: LazyListIntervalContent, + override val itemScope: LazyItemScopeImpl, + override val keyIndexMap: LazyLayoutKeyIndexMap, ) : LazyListItemProvider { - private val listContent by derivedStateOf(referentialEqualityPolicy()) { - LazyListIntervalContent(latestContent()) - } - override val itemCount: Int get() = listContent.itemCount + override val itemCount: Int get() = intervalContent.itemCount @Composable override fun Item(index: Int, key: Any) { LazyLayoutPinnableItem(key, index, state.pinnedItems) { - listContent.withInterval(index) { localIndex, content -> + intervalContent.withInterval(index) { localIndex, content -> content.item(itemScope, localIndex) } } } - override fun getKey(index: Int): Any = keyIndexMap.getKey(index) ?: listContent.getKey(index) + override fun getKey(index: Int): Any = + keyIndexMap.getKey(index) ?: intervalContent.getKey(index) - override fun getContentType(index: Int): Any? = listContent.getContentType(index) + override fun getContentType(index: Int): Any? = intervalContent.getContentType(index) - override val headerIndexes: List<Int> get() = listContent.headerIndexes - - override val keyIndexMap by NearestRangeKeyIndexMapState( - firstVisibleItemIndex = { state.firstVisibleItemIndex }, - slidingWindowSize = { NearestItemsSlidingWindowSize }, - extraItemCount = { NearestItemsExtraItemCount }, - content = { listContent } - ) + override val headerIndexes: List<Int> get() = intervalContent.headerIndexes override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key) -} -/** - * We use the idea of sliding window as an optimization, so user can scroll up to this number of - * items until we have to regenerate the key to index map. - */ -internal const val NearestItemsSlidingWindowSize = 30 + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LazyListItemProviderImpl) return false -/** - * The minimum amount of items near the current first visible item we want to have mapping for. - */ -internal const val NearestItemsExtraItemCount = 100
\ No newline at end of file + // the identity of this class is represented by intervalContent object. + // having equals() allows us to skip items recomposition when intervalContent didn't change + return intervalContent == other.intervalContent + } + + override fun hashCode(): Int { + return intervalContent.hashCode() + } +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt index c247a1dcf82..7b4d920828d 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt @@ -102,7 +102,7 @@ internal fun measureLazyList( } // this will contain all the MeasuredItems representing the visible items - val visibleItems = mutableListOf<LazyListMeasuredItem>() + val visibleItems = ArrayDeque<LazyListMeasuredItem>() // define min and max offsets val minOffset = -beforeContentPadding + if (spaceBetweenItems < 0) spaceBetweenItems else 0 @@ -403,7 +403,7 @@ private fun calculateItemsOffsets( horizontalArrangement: Arrangement.Horizontal?, reverseLayout: Boolean, density: Density, -): MutableList<LazyListPositionedItem> { +): MutableList<LazyListMeasuredItem> { val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset) if (hasSpareSpace) { @@ -411,7 +411,7 @@ private fun calculateItemsOffsets( } val positionedItems = - ArrayList<LazyListPositionedItem>(items.size + extraItemsBefore.size + extraItemsAfter.size) + ArrayList<LazyListMeasuredItem>(items.size + extraItemsBefore.size + extraItemsAfter.size) if (hasSpareSpace) { require(extraItemsBefore.isEmpty() && extraItemsAfter.isEmpty()) @@ -447,29 +447,29 @@ private fun calculateItemsOffsets( } else { absoluteOffset } - positionedItems.add(item.position(relativeOffset, layoutWidth, layoutHeight)) + item.position(relativeOffset, layoutWidth, layoutHeight) + positionedItems.add(item) } } else { var currentMainAxis = itemsScrollOffset extraItemsBefore.fastForEach { currentMainAxis -= it.sizeWithSpacings - positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedItems.add(it) } currentMainAxis = itemsScrollOffset items.fastForEach { - positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedItems.add(it) currentMainAxis += it.sizeWithSpacings } extraItemsAfter.fastForEach { - positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedItems.add(it) currentMainAxis += it.sizeWithSpacings } } return positionedItems } - -private val EmptyRange = Int.MIN_VALUE to Int.MIN_VALUE -private val Int.notInEmptyRange - get() = this != Int.MIN_VALUE
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt index 0c4b130f962..5fb7dc952f8 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt @@ -23,15 +23,16 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed /** * Represents one measured item of the lazy list. It can in fact consist of multiple placeables * if the user emit multiple layout nodes in the item callback. */ internal class LazyListMeasuredItem @ExperimentalFoundationApi constructor( - val index: Int, + override val index: Int, private val placeables: List<Placeable>, - private val isVertical: Boolean, + val isVertical: Boolean, private val horizontalAlignment: Alignment.Horizontal?, private val verticalAlignment: Alignment.Vertical?, private val layoutDirection: LayoutDirection, @@ -48,13 +49,16 @@ internal class LazyListMeasuredItem @ExperimentalFoundationApi constructor( * value passed into the place() call. */ private val visualOffset: IntOffset, - val key: Any, - private val contentType: Any? -) { + override val key: Any, + override val contentType: Any? +) : LazyListItemInfo { + override var offset: Int = 0 + private set + /** * Sum of the main axis sizes of all the inner placeables. */ - val size: Int + override val size: Int /** * Sum of the main axis sizes of all the inner placeables and [spacing]. @@ -66,6 +70,14 @@ internal class LazyListMeasuredItem @ExperimentalFoundationApi constructor( */ val crossAxisSize: Int + private var mainAxisLayoutSize: Int = Unset + private var minMainAxisOffset: Int = 0 + private var maxMainAxisOffset: Int = 0 + + // optimized for storing x and y offsets for each placeable one by one. + // array's size == placeables.size * 2, first we store x, then y. + private val placeableOffsets: IntArray + init { var mainAxisSize = 0 var maxCrossAxis = 0 @@ -76,6 +88,7 @@ internal class LazyListMeasuredItem @ExperimentalFoundationApi constructor( size = mainAxisSize sizeWithSpacings = (size + spacing).coerceAtLeast(0) crossAxisSize = maxCrossAxis + placeableOffsets = IntArray(placeables.size * 2) } val placeablesCount: Int get() = placeables.size @@ -90,64 +103,37 @@ internal class LazyListMeasuredItem @ExperimentalFoundationApi constructor( offset: Int, layoutWidth: Int, layoutHeight: Int - ): LazyListPositionedItem { - val wrappers = mutableListOf<LazyListPlaceableWrapper>() - val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth + ) { + this.offset = offset + mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth var mainAxisOffset = offset - placeables.fastForEach { - val placeableOffset = if (isVertical) { - val x = requireNotNull(horizontalAlignment) - .align(it.width, layoutWidth, layoutDirection) - IntOffset(x, mainAxisOffset) + placeables.fastForEachIndexed { index, placeable -> + val indexInArray = index * 2 + if (isVertical) { + placeableOffsets[indexInArray] = requireNotNull(horizontalAlignment) + .align(placeable.width, layoutWidth, layoutDirection) + placeableOffsets[indexInArray + 1] = mainAxisOffset + mainAxisOffset += placeable.height } else { - val y = requireNotNull(verticalAlignment).align(it.height, layoutHeight) - IntOffset(mainAxisOffset, y) + placeableOffsets[indexInArray] = mainAxisOffset + placeableOffsets[indexInArray + 1] = requireNotNull(verticalAlignment) + .align(placeable.height, layoutHeight) + mainAxisOffset += placeable.width } - mainAxisOffset += if (isVertical) it.height else it.width - wrappers.add(LazyListPlaceableWrapper(placeableOffset, it)) } - return LazyListPositionedItem( - offset = offset, - index = this.index, - key = key, - size = size, - minMainAxisOffset = -beforeContentPadding, - maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding, - isVertical = isVertical, - wrappers = wrappers, - visualOffset = visualOffset, - reverseLayout = reverseLayout, - mainAxisLayoutSize = mainAxisLayoutSize, - contentType = contentType - ) + minMainAxisOffset = -beforeContentPadding + maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding } -} - -internal class LazyListPositionedItem( - override val offset: Int, - override val index: Int, - override val key: Any, - override val size: Int, - private val minMainAxisOffset: Int, - private val maxMainAxisOffset: Int, - val isVertical: Boolean, - private val wrappers: List<LazyListPlaceableWrapper>, - private val visualOffset: IntOffset, - private val reverseLayout: Boolean, - private val mainAxisLayoutSize: Int, - override val contentType: Any? -) : LazyListItemInfo { - val placeablesCount: Int get() = wrappers.size - - fun getOffset(index: Int) = wrappers[index].offset - fun getParentData(index: Int) = wrappers[index].placeable.parentData + fun getOffset(index: Int) = + IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1]) fun place( scope: Placeable.PlacementScope, ) = with(scope) { + require(mainAxisLayoutSize != Unset) { "position() should be called first" } repeat(placeablesCount) { index -> - val placeable = wrappers[index].placeable + val placeable = placeables[index] val minOffset = minMainAxisOffset - placeable.mainAxisSize val maxOffset = maxMainAxisOffset var offset = getOffset(index) @@ -182,7 +168,4 @@ internal class LazyListPositionedItem( IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y) } -internal class LazyListPlaceableWrapper( - val offset: IntOffset, - val placeable: Placeable -) +private const val Unset = Int.MIN_VALUE
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItemProvider.kt index 77ec0760a3f..d983cd71b49 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItemProvider.kt @@ -26,12 +26,11 @@ import androidx.compose.ui.unit.Constraints * Abstracts away the subcomposition from the measuring logic. */ @OptIn(ExperimentalFoundationApi::class) -internal class LazyListMeasuredItemProvider @ExperimentalFoundationApi constructor( +internal abstract class LazyListMeasuredItemProvider @ExperimentalFoundationApi constructor( constraints: Constraints, isVertical: Boolean, private val itemProvider: LazyListItemProvider, - private val measureScope: LazyLayoutMeasureScope, - private val measuredItemFactory: MeasuredItemFactory + private val measureScope: LazyLayoutMeasureScope ) { // the constraints we will measure child with. the main axis is not restricted val childConstraints = Constraints( @@ -44,22 +43,19 @@ internal class LazyListMeasuredItemProvider @ExperimentalFoundationApi construct * correct constraints and wrapped into [LazyListMeasuredItem]. */ fun getAndMeasure(index: Int): LazyListMeasuredItem { - val key = keyIndexMap.getKey(index) ?: itemProvider.getKey(index) + val key = itemProvider.getKey(index) val contentType = itemProvider.getContentType(index) val placeables = measureScope.measure(index, childConstraints) - return measuredItemFactory.createItem(index, key, contentType, placeables) + return createItem(index, key, contentType, placeables) } /** * Contains the mapping between the key and the index. It could contain not all the items of * the list as an optimization. **/ - val keyIndexMap: LazyLayoutKeyIndexMap = itemProvider.keyIndexMap -} + val keyIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyIndexMap -// This interface allows to avoid autoboxing on index param -internal fun interface MeasuredItemFactory { - fun createItem( + abstract fun createItem( index: Int, key: Any, contentType: Any?, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt index 16eb1c71ecb..44eade109b2 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt @@ -17,11 +17,11 @@ package androidx.compose.foundation.lazy import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState import androidx.compose.foundation.lazy.layout.findIndexByKey import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot /** * Contains the current scroll position represented by the first visible item index and the first @@ -41,6 +41,12 @@ internal class LazyListScrollPosition( /** The last know key of the item at [index] position. */ private var lastKnownFirstItemKey: Any? = null + val nearestRangeState = LazyLayoutNearestRangeState( + initialIndex, + NearestItemsSlidingWindowSize, + NearestItemsExtraItemCount + ) + /** * Updates the current scroll position based on the results of the last measurement. */ @@ -54,12 +60,8 @@ internal class LazyListScrollPosition( val scrollOffset = measureResult.firstVisibleItemScrollOffset check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" } - Snapshot.withoutReadObservation { - update( - measureResult.firstVisibleItem?.index ?: 0, - scrollOffset - ) - } + val firstIndex = measureResult.firstVisibleItem?.index ?: 0 + update(firstIndex, scrollOffset) } } @@ -88,22 +90,33 @@ internal class LazyListScrollPosition( * as the first visible one even given that its index has been changed. */ @ExperimentalFoundationApi - fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) { - Snapshot.withoutReadObservation { - update( - itemProvider.findIndexByKey(lastKnownFirstItemKey, index), - scrollOffset - ) + fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyListItemProvider, + index: Int + ): Int { + val newIndex = itemProvider.findIndexByKey(lastKnownFirstItemKey, index) + if (index != newIndex) { + this.index = newIndex + nearestRangeState.update(index) } + return newIndex } private fun update(index: Int, scrollOffset: Int) { require(index >= 0f) { "Index should be non-negative ($index)" } - if (index != this.index) { - this.index = index - } - if (scrollOffset != this.scrollOffset) { - this.scrollOffset = scrollOffset - } + this.index = index + nearestRangeState.update(index) + this.scrollOffset = scrollOffset } } + +/** + * We use the idea of sliding window as an optimization, so user can scroll up to this number of + * items until we have to regenerate the key to index map. + */ +internal const val NearestItemsSlidingWindowSize = 30 + +/** + * The minimum amount of items near the current first visible item we want to have mapping for. + */ +internal const val NearestItemsExtraItemCount = 100 diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt index 3bd36be0054..1d2cf7a396e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.layout.Remeasurement import androidx.compose.ui.layout.RemeasurementModifier import androidx.compose.ui.unit.Constraints @@ -152,7 +153,7 @@ class LazyListState constructor( /** * Needed for [animateScrollToItem]. Updated on every measure. */ - internal var density: Density by mutableStateOf(Density(1f, 1f)) + internal var density: Density = Density(1f, 1f) /** * The ScrollableController instance. We keep it as we need to call stopAnimation on it once @@ -193,7 +194,7 @@ class LazyListState constructor( * The [Remeasurement] object associated with our layout. It allows us to remeasure * synchronously during scroll. */ - internal var remeasurement: Remeasurement? by mutableStateOf(null) + internal var remeasurement: Remeasurement? = null private set /** @@ -218,13 +219,15 @@ class LazyListState constructor( /** * Constraints passed to the prefetcher for premeasuring the prefetched items. */ - internal var premeasureConstraints by mutableStateOf(Constraints()) + internal var premeasureConstraints = Constraints() /** * Stores currently pinned items which are always composed. */ internal val pinnedItems = LazyLayoutPinnedItemList() + internal val nearestRange: IntRange by scrollPosition.nearestRangeState + /** * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset] * pixels. @@ -401,9 +404,10 @@ class LazyListState constructor( * items added or removed before our current first visible item and keep this item * as the first visible one even given that its index has been changed. */ - internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) { - scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - } + internal fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyListItemProvider, + firstItemIndex: Int = Snapshot.withoutReadObservation { scrollPosition.index } + ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex) companion object { /** diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt index 4f84478fc76..385b153e953 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -75,12 +76,12 @@ internal fun LazyGrid( ) { val overscrollEffect = ScrollableDefaults.overscrollEffect() - val itemProvider = rememberLazyGridItemProvider(state, content) + val itemProviderLambda = rememberLazyGridItemProviderLambda(state, content) val semanticState = rememberLazyGridSemanticState(state, reverseLayout) val measurePolicy = rememberLazyGridMeasurePolicy( - itemProvider, + itemProviderLambda, state, slots, contentPadding, @@ -92,7 +93,7 @@ internal fun LazyGrid( state.isVertical = isVertical - ScrollPositionUpdater(itemProvider, state) + ScrollPositionUpdater(itemProviderLambda, state) val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal LazyLayout( @@ -100,7 +101,7 @@ internal fun LazyGrid( .then(state.remeasurementModifier) .then(state.awaitLayoutModifier) .lazyLayoutSemantics( - itemProvider = itemProvider, + itemProviderLambda = itemProviderLambda, state = semanticState, orientation = orientation, userScrollEnabled = userScrollEnabled, @@ -128,7 +129,7 @@ internal fun LazyGrid( ), prefetchState = state.prefetchState, measurePolicy = measurePolicy, - itemProvider = itemProvider + itemProvider = itemProviderLambda ) } @@ -136,9 +137,10 @@ internal fun LazyGrid( @OptIn(ExperimentalFoundationApi::class) @Composable private fun ScrollPositionUpdater( - itemProvider: LazyGridItemProvider, + itemProviderLambda: () -> LazyGridItemProvider, state: LazyGridState ) { + val itemProvider = itemProviderLambda() if (itemProvider.itemCount > 0) { state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) } @@ -154,7 +156,7 @@ internal class LazyGridSlots( @Composable private fun rememberLazyGridMeasurePolicy( /** Items provider of the list. */ - itemProvider: LazyGridItemProvider, + itemProviderLambda: () -> LazyGridItemProvider, /** The state of the list. */ state: LazyGridState, /** Prefix sums of cross axis sizes of slots of the grid. */ @@ -215,8 +217,7 @@ private fun rememberLazyGridMeasurePolicy( val contentConstraints = containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - + val itemProvider = itemProviderLambda() val spanLayoutProvider = itemProvider.spanLayoutProvider val resolvedSlots = slots(containerConstraints) val slotsPerLine = resolvedSlots.sizes.size @@ -252,12 +253,19 @@ private fun rememberLazyGridMeasurePolicy( ) } - val measuredItemProvider = LazyGridMeasuredItemProvider( + val measuredItemProvider = object : LazyGridMeasuredItemProvider( itemProvider, this, spaceBetweenLines - ) { index, key, contentType, crossAxisSize, mainAxisSpacing, placeables -> - LazyGridMeasuredItem( + ) { + override fun createItem( + index: Int, + key: Any, + contentType: Any?, + crossAxisSize: Int, + mainAxisSpacing: Int, + placeables: List<Placeable> + ) = LazyGridMeasuredItem( index = index, key = key, isVertical = isVertical, @@ -272,15 +280,20 @@ private fun rememberLazyGridMeasurePolicy( contentType = contentType ) } - val measuredLineProvider = LazyGridMeasuredLineProvider( + val measuredLineProvider = object : LazyGridMeasuredLineProvider( isVertical = isVertical, slots = resolvedSlots, gridItemsCount = itemsCount, spaceBetweenLines = spaceBetweenLines, measuredItemProvider = measuredItemProvider, spanLayoutProvider = spanLayoutProvider - ) { index, items, spans, mainAxisSpacing -> - LazyGridMeasuredLine( + ) { + override fun createLine( + index: Int, + items: Array<LazyGridMeasuredItem>, + spans: List<GridItemSpan>, + mainAxisSpacing: Int + ) = LazyGridMeasuredLine( index = index, items = items, spans = spans, @@ -307,10 +320,11 @@ private fun rememberLazyGridMeasurePolicy( val firstVisibleLineScrollOffset: Int Snapshot.withoutReadObservation { - if (state.firstVisibleItemIndex < itemsCount || itemsCount <= 0) { - firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem( - state.firstVisibleItemIndex - ) + val index = state.updateScrollPositionIfTheFirstItemWasMoved( + itemProvider, state.firstVisibleItemIndex + ) + if (index < itemsCount || itemsCount <= 0) { + firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem(index) firstVisibleLineScrollOffset = state.firstVisibleItemScrollOffset } else { // the data set has been updated and now we have less items that we were diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt index fbd96ee7e8a..918f2a2881f 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt @@ -41,8 +41,8 @@ internal class LazyGridItemPlacementAnimator { // stored to not allocate it every pass. private val movingAwayKeys = LinkedHashSet<Any>() - private val movingInFromStartBound = mutableListOf<LazyGridPositionedItem>() - private val movingInFromEndBound = mutableListOf<LazyGridPositionedItem>() + private val movingInFromStartBound = mutableListOf<LazyGridMeasuredItem>() + private val movingInFromEndBound = mutableListOf<LazyGridMeasuredItem>() private val movingAwayToStartBound = mutableListOf<LazyGridMeasuredItem>() private val movingAwayToEndBound = mutableListOf<LazyGridMeasuredItem>() @@ -55,7 +55,7 @@ internal class LazyGridItemPlacementAnimator { consumedScroll: Int, layoutWidth: Int, layoutHeight: Int, - positionedItems: MutableList<LazyGridPositionedItem>, + positionedItems: MutableList<LazyGridMeasuredItem>, itemProvider: LazyGridMeasuredItemProvider, spanLayoutProvider: LazyGridSpanLayoutProvider, isVertical: Boolean @@ -91,7 +91,7 @@ internal class LazyGridItemPlacementAnimator { // there is no state associated with this item yet if (itemInfo == null) { keyToItemInfoMap[item.key] = - ItemInfo(item.getCrossAxisSize(), item.getCrossAxisOffset()) + ItemInfo(item.crossAxisSize, item.crossAxisOffset) val previousIndex = previousKeyToIndexMap.getIndex(item.key) if (previousIndex != -1 && item.index != previousIndex) { if (previousIndex < previousFirstVisibleIndex) { @@ -112,8 +112,8 @@ internal class LazyGridItemPlacementAnimator { it.rawOffset += scrollOffset } } - itemInfo.crossAxisSize = item.getCrossAxisSize() - itemInfo.crossAxisOffset = item.getCrossAxisOffset() + itemInfo.crossAxisSize = item.crossAxisSize + itemInfo.crossAxisOffset = item.crossAxisOffset startAnimationsIfNeeded(item) } } else { @@ -129,13 +129,13 @@ internal class LazyGridItemPlacementAnimator { movingInFromStartBound.fastForEach { item -> val line = if (isVertical) item.row else item.column if (line != -1 && line == previousLine) { - previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.getMainAxisSize()) + previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize) } else { accumulatedOffset += previousLineMainAxisSize - previousLineMainAxisSize = item.getMainAxisSize() + previousLineMainAxisSize = item.mainAxisSize previousLine = line } - val mainAxisOffset = 0 - accumulatedOffset - item.getMainAxisSize() + val mainAxisOffset = 0 - accumulatedOffset - item.mainAxisSize initializeNode(item, mainAxisOffset) startAnimationsIfNeeded(item) } @@ -146,10 +146,10 @@ internal class LazyGridItemPlacementAnimator { movingInFromEndBound.fastForEach { item -> val line = if (isVertical) item.row else item.column if (line != -1 && line == previousLine) { - previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.getMainAxisSize()) + previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize) } else { accumulatedOffset += previousLineMainAxisSize - previousLineMainAxisSize = item.getMainAxisSize() + previousLineMainAxisSize = item.mainAxisSize previousLine = line } val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset @@ -211,16 +211,14 @@ internal class LazyGridItemPlacementAnimator { val itemInfo = keyToItemInfoMap.getValue(item.key) - val positionedItem = item.position( - mainAxisOffset, - itemInfo.crossAxisOffset, - layoutWidth, - layoutHeight, - LazyGridItemInfo.UnknownRow, - LazyGridItemInfo.UnknownColumn + item.position( + mainAxisOffset = mainAxisOffset, + crossAxisOffset = itemInfo.crossAxisOffset, + layoutWidth = layoutWidth, + layoutHeight = layoutHeight ) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + positionedItems.add(item) + startAnimationsIfNeeded(item) } accumulatedOffset = 0 previousLine = -1 @@ -238,17 +236,15 @@ internal class LazyGridItemPlacementAnimator { val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset val itemInfo = keyToItemInfoMap.getValue(item.key) - val positionedItem = item.position( - mainAxisOffset, - itemInfo.crossAxisOffset, - layoutWidth, - layoutHeight, - LazyGridItemInfo.UnknownRow, - LazyGridItemInfo.UnknownColumn + item.position( + mainAxisOffset = mainAxisOffset, + crossAxisOffset = itemInfo.crossAxisOffset, + layoutWidth = layoutWidth, + layoutHeight = layoutHeight, ) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + positionedItems.add(item) + startAnimationsIfNeeded(item) } movingInFromStartBound.clear() @@ -269,7 +265,7 @@ internal class LazyGridItemPlacementAnimator { } private fun initializeNode( - item: LazyGridPositionedItem, + item: LazyGridMeasuredItem, mainAxisOffset: Int ) { val firstPlaceableOffset = item.offset @@ -288,7 +284,7 @@ internal class LazyGridItemPlacementAnimator { } } - private fun startAnimationsIfNeeded(item: LazyGridPositionedItem) { + private fun startAnimationsIfNeeded(item: LazyGridMeasuredItem) { item.forEachNode { node -> val newTarget = item.offset val currentTarget = node.rawOffset @@ -303,13 +299,13 @@ internal class LazyGridItemPlacementAnimator { private val Any?.node get() = this as? LazyLayoutAnimateItemModifierNode - private val LazyGridPositionedItem.hasAnimations: Boolean + private val LazyGridMeasuredItem.hasAnimations: Boolean get() { forEachNode { return true } return false } - private inline fun LazyGridPositionedItem.forEachNode( + private inline fun LazyGridMeasuredItem.forEachNode( block: (LazyLayoutAnimateItemModifierNode) -> Unit ) { repeat(placeablesCount) { index -> diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt index 95c2af305b6..55d346b836d 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt @@ -20,10 +20,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem -import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMapState +import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMap import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -36,63 +35,66 @@ internal interface LazyGridItemProvider : LazyLayoutItemProvider { @ExperimentalFoundationApi @Composable -internal fun rememberLazyGridItemProvider( +internal fun rememberLazyGridItemProviderLambda( state: LazyGridState, content: LazyGridScope.() -> Unit, -): LazyGridItemProvider { +): () -> LazyGridItemProvider { val latestContent = rememberUpdatedState(content) return remember(state) { - LazyGridItemProviderImpl( - state, - { latestContent.value }, - ) + val intervalContentState = derivedStateOf(referentialEqualityPolicy()) { + LazyGridIntervalContent(latestContent.value) + } + val itemProviderState = derivedStateOf(referentialEqualityPolicy()) { + val intervalContent = intervalContentState.value + val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent) + LazyGridItemProviderImpl( + state = state, + intervalContent = intervalContent, + keyIndexMap = map + ) + } + itemProviderState::value } } @ExperimentalFoundationApi private class LazyGridItemProviderImpl( private val state: LazyGridState, - private val latestContent: () -> (LazyGridScope.() -> Unit) + private val intervalContent: LazyGridIntervalContent, + override val keyIndexMap: LazyLayoutKeyIndexMap, ) : LazyGridItemProvider { - private val gridContent by derivedStateOf(referentialEqualityPolicy()) { - LazyGridIntervalContent(latestContent()) - } - override val keyIndexMap: LazyLayoutKeyIndexMap by NearestRangeKeyIndexMapState( - firstVisibleItemIndex = { state.firstVisibleItemIndex }, - slidingWindowSize = { NearestItemsSlidingWindowSize }, - extraItemCount = { NearestItemsExtraItemCount }, - content = { gridContent } - ) + override val itemCount: Int get() = intervalContent.itemCount - override val itemCount: Int get() = gridContent.itemCount + override fun getKey(index: Int): Any = + keyIndexMap.getKey(index) ?: intervalContent.getKey(index) - override fun getKey(index: Int): Any = keyIndexMap.getKey(index) ?: gridContent.getKey(index) - - override fun getContentType(index: Int): Any? = gridContent.getContentType(index) + override fun getContentType(index: Int): Any? = intervalContent.getContentType(index) @Composable override fun Item(index: Int, key: Any) { LazyLayoutPinnableItem(key, index, state.pinnedItems) { - gridContent.withInterval(index) { localIndex, content -> + intervalContent.withInterval(index) { localIndex, content -> content.item(LazyGridItemScopeImpl, localIndex) } } } override val spanLayoutProvider: LazyGridSpanLayoutProvider - get() = gridContent.spanLayoutProvider + get() = intervalContent.spanLayoutProvider override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key) -} -/** - * We use the idea of sliding window as an optimization, so user can scroll up to this number of - * items until we have to regenerate the key to index map. - */ -private const val NearestItemsSlidingWindowSize = 90 + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LazyGridItemProviderImpl) return false -/** - * The minimum amount of items near the current first visible item we want to have mapping for. - */ -private const val NearestItemsExtraItemCount = 200
\ No newline at end of file + // the identity of this class is represented by intervalContent object. + // having equals() allows us to skip items recomposition when intervalContent didn't change + return intervalContent == other.intervalContent + } + + override fun hashCode(): Int { + return intervalContent.hashCode() + } +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt index d5fa3ecadbf..b9258895247 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt @@ -98,7 +98,7 @@ internal fun measureLazyGrid( } // this will contain all the MeasuredItems representing the visible lines - val visibleLines = mutableListOf<LazyGridMeasuredLine>() + val visibleLines = ArrayDeque<LazyGridMeasuredLine>() // define min and max offsets val minOffset = -beforeContentPadding + if (spaceBetweenLines < 0) spaceBetweenLines else 0 @@ -343,14 +343,14 @@ private fun calculateItemsOffsets( horizontalArrangement: Arrangement.Horizontal?, reverseLayout: Boolean, density: Density, -): MutableList<LazyGridPositionedItem> { +): MutableList<LazyGridMeasuredItem> { val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth val hasSpareSpace = finalMainAxisOffset < min(mainAxisLayoutSize, maxOffset) if (hasSpareSpace) { check(firstLineScrollOffset == 0) } - val positionedItems = ArrayList<LazyGridPositionedItem>(lines.fastSumBy { it.items.size }) + val positionedItems = ArrayList<LazyGridMeasuredItem>(lines.fastSumBy { it.items.size }) if (hasSpareSpace) { require(itemsBefore.isEmpty() && itemsAfter.isEmpty()) @@ -395,7 +395,8 @@ private fun calculateItemsOffsets( itemsBefore.fastForEach { currentMainAxis -= it.mainAxisSizeWithSpacings - positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, 0, layoutWidth, layoutHeight) + positionedItems.add(it) } currentMainAxis = firstLineScrollOffset @@ -405,23 +406,10 @@ private fun calculateItemsOffsets( } itemsAfter.fastForEach { - positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, 0, layoutWidth, layoutHeight) + positionedItems.add(it) currentMainAxis += it.mainAxisSizeWithSpacings } } return positionedItems } - -private fun LazyGridMeasuredItem.positionExtraItem( - mainAxisOffset: Int, - layoutWidth: Int, - layoutHeight: Int -): LazyGridPositionedItem = - position( - mainAxisOffset = mainAxisOffset, - crossAxisOffset = 0, - layoutWidth = layoutWidth, - layoutHeight = layoutHeight, - row = -1, - column = -1 - ) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt index 74a6896e2c9..ecf9ff7f2ec 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt @@ -28,27 +28,27 @@ import androidx.compose.ui.util.fastForEach * if the user emit multiple layout nodes in the item callback. */ internal class LazyGridMeasuredItem( - val index: Int, - val key: Any, - private val isVertical: Boolean, + override val index: Int, + override val key: Any, + val isVertical: Boolean, /** * Cross axis size is the same for all [placeables]. Take it as parameter for the case when * [placeables] is empty. */ val crossAxisSize: Int, - val mainAxisSpacing: Int, + mainAxisSpacing: Int, private val reverseLayout: Boolean, private val layoutDirection: LayoutDirection, private val beforeContentPadding: Int, private val afterContentPadding: Int, - val placeables: List<Placeable>, + private val placeables: List<Placeable>, /** * The offset which shouldn't affect any calculations but needs to be applied for the final * value passed into the place() call. */ private val visualOffset: IntOffset, - private val contentType: Any? -) { + override val contentType: Any? +) : LazyGridItemInfo { /** * Main axis size of the item - the max main axis size of the placeables. */ @@ -61,6 +61,10 @@ internal class LazyGridMeasuredItem( val placeablesCount: Int get() = placeables.size + private var mainAxisLayoutSize: Int = Unset + private var minMainAxisOffset: Int = 0 + private var maxMainAxisOffset: Int = 0 + fun getParentData(index: Int) = placeables[index].parentData init { @@ -72,6 +76,19 @@ internal class LazyGridMeasuredItem( mainAxisSizeWithSpacings = (maxMainAxis + mainAxisSpacing).coerceAtLeast(0) } + override val size: IntSize = if (isVertical) { + IntSize(crossAxisSize, mainAxisSize) + } else { + IntSize(mainAxisSize, crossAxisSize) + } + override var offset: IntOffset = IntOffset.Zero + private set + val crossAxisOffset get() = if (isVertical) offset.x else offset.y + override var row: Int = LazyGridItemInfo.UnknownRow + private set + override var column: Int = LazyGridItemInfo.UnknownColumn + private set + /** * Calculates positions for the inner placeables at [mainAxisOffset], [crossAxisOffset]. * [layoutWidth] and [layoutHeight] should be provided to not place placeables which are ended @@ -84,10 +101,10 @@ internal class LazyGridMeasuredItem( crossAxisOffset: Int, layoutWidth: Int, layoutHeight: Int, - row: Int, - column: Int - ): LazyGridPositionedItem { - val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth + row: Int = LazyGridItemInfo.UnknownRow, + column: Int = LazyGridItemInfo.UnknownColumn + ) { + mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth val crossAxisLayoutSize = if (isVertical) layoutWidth else layoutHeight @Suppress("NAME_SHADOWING") val crossAxisOffset = if (isVertical && layoutDirection == LayoutDirection.Rtl) { @@ -95,62 +112,21 @@ internal class LazyGridMeasuredItem( } else { crossAxisOffset } - return LazyGridPositionedItem( - offset = if (isVertical) { - IntOffset(crossAxisOffset, mainAxisOffset) - } else { - IntOffset(mainAxisOffset, crossAxisOffset) - }, - index = index, - key = key, - row = row, - column = column, - size = if (isVertical) { - IntSize(crossAxisSize, mainAxisSize) - } else { - IntSize(mainAxisSize, crossAxisSize) - }, - minMainAxisOffset = -beforeContentPadding, - maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding, - isVertical = isVertical, - placeables = placeables, - visualOffset = visualOffset, - mainAxisLayoutSize = mainAxisLayoutSize, - reverseLayout = reverseLayout, - contentType = contentType - ) + offset = if (isVertical) { + IntOffset(crossAxisOffset, mainAxisOffset) + } else { + IntOffset(mainAxisOffset, crossAxisOffset) + } + this.row = row + this.column = column + minMainAxisOffset = -beforeContentPadding + maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding } -} - -internal class LazyGridPositionedItem( - override val offset: IntOffset, - override val index: Int, - override val key: Any, - override val row: Int, - override val column: Int, - override val size: IntSize, - private val minMainAxisOffset: Int, - private val maxMainAxisOffset: Int, - val isVertical: Boolean, - private val placeables: List<Placeable>, - private val visualOffset: IntOffset, - private val mainAxisLayoutSize: Int, - private val reverseLayout: Boolean, - override val contentType: Any? -) : LazyGridItemInfo { - val placeablesCount: Int get() = placeables.size - - fun getMainAxisSize() = if (isVertical) size.height else size.width - - fun getCrossAxisSize() = if (isVertical) size.width else size.height - - fun getCrossAxisOffset() = if (isVertical) offset.x else offset.y - - fun getParentData(index: Int) = placeables[index].parentData fun place( scope: Placeable.PlacementScope, ) = with(scope) { + require(mainAxisLayoutSize != Unset) { "position() should be called first" } repeat(placeablesCount) { index -> val placeable = placeables[index] val minOffset = minMainAxisOffset - placeable.mainAxisSize @@ -187,3 +163,5 @@ internal class LazyGridPositionedItem( private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset = IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y) } + +private const val Unset = Int.MIN_VALUE
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItemProvider.kt index c9846316c71..05be19d8eba 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItemProvider.kt @@ -26,11 +26,10 @@ import androidx.compose.ui.unit.Constraints * Abstracts away the subcomposition from the measuring logic. */ @OptIn(ExperimentalFoundationApi::class) -internal class LazyGridMeasuredItemProvider @ExperimentalFoundationApi constructor( +internal abstract class LazyGridMeasuredItemProvider @ExperimentalFoundationApi constructor( private val itemProvider: LazyGridItemProvider, private val measureScope: LazyLayoutMeasureScope, - private val defaultMainAxisSpacing: Int, - private val measuredItemFactory: MeasuredItemFactory + private val defaultMainAxisSpacing: Int ) { /** * Used to subcompose individual items of lazy grids. Composed placeables will be measured @@ -41,7 +40,7 @@ internal class LazyGridMeasuredItemProvider @ExperimentalFoundationApi construct mainAxisSpacing: Int = defaultMainAxisSpacing, constraints: Constraints ): LazyGridMeasuredItem { - val key = keyIndexMap.getKey(index) ?: itemProvider.getKey(index) + val key = itemProvider.getKey(index) val contentType = itemProvider.getContentType(index) val placeables = measureScope.measure(index, constraints) val crossAxisSize = if (constraints.hasFixedWidth) { @@ -50,7 +49,7 @@ internal class LazyGridMeasuredItemProvider @ExperimentalFoundationApi construct require(constraints.hasFixedHeight) constraints.minHeight } - return measuredItemFactory.createItem( + return createItem( index, key, contentType, @@ -64,12 +63,9 @@ internal class LazyGridMeasuredItemProvider @ExperimentalFoundationApi construct * Contains the mapping between the key and the index. It could contain not all the items of * the list as an optimization. **/ - val keyIndexMap: LazyLayoutKeyIndexMap = itemProvider.keyIndexMap -} + val keyIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyIndexMap -// This interface allows to avoid autoboxing on index param -internal fun interface MeasuredItemFactory { - fun createItem( + abstract fun createItem( index: Int, key: Any, contentType: Any?, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt index 0b3f5e04527..f5f8349b8a1 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt @@ -66,9 +66,9 @@ internal class LazyGridMeasuredLine constructor( offset: Int, layoutWidth: Int, layoutHeight: Int - ): List<LazyGridPositionedItem> { + ): Array<LazyGridMeasuredItem> { var usedSpan = 0 - return items.mapIndexed { itemIndex, item -> + items.forEachIndexed { itemIndex, item -> val span = spans[itemIndex].currentLineSpan val startSlot = usedSpan @@ -83,5 +83,6 @@ internal class LazyGridMeasuredLine constructor( usedSpan += span } } + return items } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt index fe5ff5498b3..c852c3f3fca 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt @@ -24,14 +24,13 @@ import androidx.compose.ui.unit.Constraints * Abstracts away subcomposition and span calculation from the measuring logic of entire lines. */ @OptIn(ExperimentalFoundationApi::class) -internal class LazyGridMeasuredLineProvider( +internal abstract class LazyGridMeasuredLineProvider( private val isVertical: Boolean, private val slots: LazyGridSlots, private val gridItemsCount: Int, private val spaceBetweenLines: Int, private val measuredItemProvider: LazyGridMeasuredItemProvider, - private val spanLayoutProvider: LazyGridSpanLayoutProvider, - private val measuredLineFactory: MeasuredLineFactory + private val spanLayoutProvider: LazyGridSpanLayoutProvider ) { // The constraints for cross axis size. The main axis is not restricted. internal fun childConstraints(startSlot: Int, span: Int): Constraints { @@ -67,7 +66,8 @@ internal class LazyGridMeasuredLineProvider( // we add space between lines as an extra spacing for all lines apart from the last one // so the lazy grid measuring logic will take it into account. val mainAxisSpacing = if (lineItemsCount == 0 || - lineConfiguration.firstItemIndex + lineItemsCount == gridItemsCount) { + lineConfiguration.firstItemIndex + lineItemsCount == gridItemsCount + ) { 0 } else { spaceBetweenLines @@ -83,7 +83,7 @@ internal class LazyGridMeasuredLineProvider( constraints ).also { startSlot += span } } - return measuredLineFactory.createLine( + return createLine( lineIndex, items, lineConfiguration.spans, @@ -96,12 +96,8 @@ internal class LazyGridMeasuredLineProvider( * the list as an optimization. **/ val keyIndexMap: LazyLayoutKeyIndexMap get() = measuredItemProvider.keyIndexMap -} -// This interface allows to avoid autoboxing on index param -@OptIn(ExperimentalFoundationApi::class) -internal fun interface MeasuredLineFactory { - fun createLine( + abstract fun createLine( index: Int, items: Array<LazyGridMeasuredItem>, spans: List<GridItemSpan>, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt index 7b196194664..7ec7f7020b4 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt @@ -17,11 +17,11 @@ package androidx.compose.foundation.lazy.grid import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState import androidx.compose.foundation.lazy.layout.findIndexByKey import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot /** * Contains the current scroll position represented by the first visible item index and the first @@ -43,6 +43,12 @@ internal class LazyGridScrollPosition( /** The last known key of the first item at [index] line. */ private var lastKnownFirstItemKey: Any? = null + val nearestRangeState = LazyLayoutNearestRangeState( + initialIndex, + NearestItemsSlidingWindowSize, + NearestItemsExtraItemCount + ) + /** * Updates the current scroll position based on the results of the last measurement. */ @@ -56,12 +62,8 @@ internal class LazyGridScrollPosition( val scrollOffset = measureResult.firstVisibleLineScrollOffset check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" } - Snapshot.withoutReadObservation { - update( - measureResult.firstVisibleLine?.items?.firstOrNull()?.index ?: 0, - scrollOffset - ) - } + val firstIndex = measureResult.firstVisibleLine?.items?.firstOrNull()?.index ?: 0 + update(firstIndex, scrollOffset) } } @@ -89,22 +91,33 @@ internal class LazyGridScrollPosition( * there were items added or removed before our current first visible item and keep this item * as the first visible one even given that its index has been changed. */ - fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) { - Snapshot.withoutReadObservation { - update( - itemProvider.findIndexByKey(lastKnownFirstItemKey, index), - scrollOffset - ) + fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyGridItemProvider, + index: Int + ): Int { + val newIndex = itemProvider.findIndexByKey(lastKnownFirstItemKey, index) + if (index != newIndex) { + this.index = newIndex + nearestRangeState.update(index) } + return newIndex } private fun update(index: Int, scrollOffset: Int) { require(index >= 0f) { "Index should be non-negative ($index)" } - if (index != this.index) { - this.index = index - } - if (scrollOffset != this.scrollOffset) { - this.scrollOffset = scrollOffset - } + this.index = index + nearestRangeState.update(index) + this.scrollOffset = scrollOffset } } + +/** + * We use the idea of sliding window as an optimization, so user can scroll up to this number of + * items until we have to regenerate the key to index map. + */ +private const val NearestItemsSlidingWindowSize = 90 + +/** + * The minimum amount of items near the current first visible item we want to have mapping for. + */ +private const val NearestItemsExtraItemCount = 200 diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt index 73e2a21b4f7..2ab8a169a16 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.layout.Remeasurement import androidx.compose.ui.layout.RemeasurementModifier import androidx.compose.ui.unit.Constraints @@ -155,12 +156,12 @@ class LazyGridState constructor( /** * Needed for [animateScrollToItem]. Updated on every measure. */ - internal var density: Density by mutableStateOf(Density(1f, 1f)) + internal var density: Density = Density(1f, 1f) /** * Needed for [notifyPrefetch]. */ - internal var isVertical: Boolean by mutableStateOf(true) + internal var isVertical: Boolean = true /** * The ScrollableController instance. We keep it as we need to call stopAnimation on it once @@ -202,7 +203,7 @@ class LazyGridState constructor( * The [Remeasurement] object associated with our layout. It allows us to remeasure * synchronously during scroll. */ - internal var remeasurement: Remeasurement? by mutableStateOf(null) + internal var remeasurement: Remeasurement? = null /** * The modifier which provides [remeasurement]. @@ -236,6 +237,8 @@ class LazyGridState constructor( */ internal val pinnedItems = LazyLayoutPinnedItemList() + internal val nearestRange: IntRange by scrollPosition.nearestRangeState + /** * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset] * pixels. @@ -428,9 +431,10 @@ class LazyGridState constructor( * items added or removed before our current first visible item and keep this item * as the first visible one even given that its index has been changed. */ - internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) { - scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - } + internal fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyGridItemProvider, + firstItemIndex: Int = Snapshot.withoutReadObservation { scrollPosition.index } + ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex) companion object { /** diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt index f43b2748ddd..ffc8ddc6c3e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt @@ -45,11 +45,22 @@ fun LazyLayout( prefetchState: LazyLayoutPrefetchState? = null, measurePolicy: LazyLayoutMeasureScope.(Constraints) -> MeasureResult ) { + LazyLayout({ itemProvider }, modifier, prefetchState, measurePolicy) +} + +@ExperimentalFoundationApi +@Composable +internal fun LazyLayout( + itemProvider: () -> LazyLayoutItemProvider, + modifier: Modifier = Modifier, + prefetchState: LazyLayoutPrefetchState? = null, + measurePolicy: LazyLayoutMeasureScope.(Constraints) -> MeasureResult +) { val currentItemProvider = rememberUpdatedState(itemProvider) LazySaveableStateHolderProvider { saveableStateHolder -> val itemContentFactory = remember { - LazyLayoutItemContentFactory(saveableStateHolder) { currentItemProvider.value } + LazyLayoutItemContentFactory(saveableStateHolder) { currentItemProvider.value() } } val subcomposeLayoutState = remember { SubcomposeLayoutState(LazyLayoutItemReusePolicy(itemContentFactory)) @@ -67,11 +78,13 @@ fun LazyLayout( modifier, remember(itemContentFactory, measurePolicy) { { constraints -> - with(LazyLayoutMeasureScopeImpl( - itemContentFactory, - this, - prefetchState?.prefetcher?.timeTracker - )) { + with( + LazyLayoutMeasureScopeImpl( + itemContentFactory, + this, + prefetchState?.prefetcher?.timeTracker + ) + ) { measurePolicy(constraints) } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemContentFactory.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemContentFactory.kt index 0b3039be798..c6a41abd995 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemContentFactory.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemContentFactory.kt @@ -21,10 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.ReusableContentHost import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.setValue /** * This class: @@ -52,7 +49,7 @@ internal class LazyLayoutItemContentFactory( val cachedContent = lambdasCache[key] return if (cachedContent != null) { - cachedContent.type + cachedContent.contentType } else { val itemProvider = itemProvider() val index = itemProvider.getIndex(key) @@ -67,24 +64,24 @@ internal class LazyLayoutItemContentFactory( /** * Return cached item content lambda or creates a new lambda and puts it in the cache. */ - fun getContent(index: Int, key: Any): @Composable () -> Unit { + fun getContent(index: Int, key: Any, contentType: Any?): @Composable () -> Unit { val cached = lambdasCache[key] - val type = itemProvider().getContentType(index) - return if (cached != null && cached.lastKnownIndex == index && cached.type == type) { + return if (cached != null && cached.index == index && cached.contentType == contentType) { cached.content } else { - val newContent = CachedItemContent(index, key, type) + val newContent = CachedItemContent(index, key, contentType) lambdasCache[key] = newContent newContent.content } } private inner class CachedItemContent( - initialIndex: Int, + index: Int, val key: Any, - val type: Any? + val contentType: Any? ) { - var lastKnownIndex by mutableIntStateOf(initialIndex) + // the index resolved during the latest composition + var index = index private set private var _content: (@Composable () -> Unit)? = null @@ -94,10 +91,10 @@ internal class LazyLayoutItemContentFactory( private fun createContentLambda() = @Composable { val itemProvider = itemProvider() - var index = lastKnownIndex + var index = index if (index >= itemProvider.itemCount || itemProvider.getKey(index) != key) { index = itemProvider.getIndex(key) - if (index != -1) lastKnownIndex = index + if (index != -1) this.index = index } ReusableContentHost(active = index != -1) { diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt index 9498578b954..e9f28587d31 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt @@ -70,7 +70,7 @@ internal fun LazyLayoutItemProvider.findIndexByKey( key: Any?, lastKnownIndex: Int, ): Int { - if (key == null) { + if (key == null || itemCount == 0) { // there were no real item during the previous measure return lastKnownIndex } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt index a6485eea17d..8ba9d37d270 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt @@ -17,11 +17,6 @@ package androidx.compose.foundation.lazy.layout import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.referentialEqualityPolicy -import androidx.compose.runtime.structuralEqualityPolicy /** * A key-index mapping used inside the [LazyLayoutItemProvider]. It might not contain all items @@ -51,60 +46,22 @@ internal interface LazyLayoutKeyIndexMap { } /** - * State containing [LazyLayoutKeyIndexMap] precalculated for range of indexes near first visible - * item. - * It is optimized to return the same range for small changes in the firstVisibleItemIndex - * value so we do not regenerate the map on each scroll. - * - * @param firstVisibleItemIndex Provider of the first item index currently visible on screen. - * @param slidingWindowSize Number of items between current and `firstVisibleItem` until - * [LazyLayoutKeyIndexMap] is regenerated. - * @param extraItemCount The minimum amount of items in one direction near the first visible item - * to calculate mapping for. - * @param content Provider of [LazyLayoutIntervalContent] to generate key index mapping for. - */ -@ExperimentalFoundationApi -internal class NearestRangeKeyIndexMapState( - firstVisibleItemIndex: () -> Int, - slidingWindowSize: () -> Int, - extraItemCount: () -> Int, - content: () -> LazyLayoutIntervalContent<*> -) : State<LazyLayoutKeyIndexMap> { - private val nearestRangeState by derivedStateOf(structuralEqualityPolicy()) { - if (content().itemCount < extraItemCount() * 2 + slidingWindowSize()) { - 0 until content().itemCount - } else { - calculateNearestItemsRange( - firstVisibleItemIndex(), - slidingWindowSize(), - extraItemCount() - ) - } - } - - override val value: LazyLayoutKeyIndexMap by derivedStateOf(referentialEqualityPolicy()) { - NearestRangeKeyIndexMap(nearestRangeState, content()) - } -} - -/** * Implementation of [LazyLayoutKeyIndexMap] indexing over given [IntRange] of items. * Items outside of given range are considered unknown, with null returned as the index. */ @ExperimentalFoundationApi -private class NearestRangeKeyIndexMap( +internal class NearestRangeKeyIndexMap( nearestRange: IntRange, - content: LazyLayoutIntervalContent<*> + intervalContent: LazyLayoutIntervalContent<*> ) : LazyLayoutKeyIndexMap { private val map: Map<Any, Int> private val keys: Array<Any?> private val keysStartIndex: Int init { - // Traverses the interval [list] in order to create a mapping from the key to the index for all - // the indexes in the passed [range]. - // The returned map will not contain the values for intervals with no key mapping provided. - val list = content.intervals + // Traverses the interval [list] in order to create a mapping from the key to the index for + // all the indexes in the passed [range]. + val list = intervalContent.intervals val first = nearestRange.first check(first >= 0) val last = minOf(nearestRange.last, list.size - 1) @@ -113,31 +70,24 @@ private class NearestRangeKeyIndexMap( keys = emptyArray() keysStartIndex = 0 } else { - var tmpKeys = emptyArray<Any?>() - var tmpKeysStartIndex = 0 + keys = arrayOfNulls<Any?>(last - first + 1) + keysStartIndex = first map = hashMapOf<Any, Int>().also { map -> list.forEach( fromIndex = first, toIndex = last, ) { - if (it.value.key != null) { - val keyFactory = requireNotNull(it.value.key) - val start = maxOf(first, it.startIndex) - if (tmpKeys.isEmpty()) { - tmpKeysStartIndex = start - tmpKeys = Array(last - start + 1) { null } - } - val end = minOf(last, it.startIndex + it.size - 1) - for (i in start..end) { - val key = keyFactory(i - it.startIndex) - map[key] = i - tmpKeys[i - tmpKeysStartIndex] = key - } + val keyFactory = it.value.key + val start = maxOf(first, it.startIndex) + val end = minOf(last, it.startIndex + it.size - 1) + for (i in start..end) { + val key = + keyFactory?.invoke(i - it.startIndex) ?: getDefaultLazyLayoutKey(i) + map[key] = i + keys[i - keysStartIndex] = key } } } - keys = tmpKeys - keysStartIndex = tmpKeysStartIndex } } @@ -146,20 +96,3 @@ private class NearestRangeKeyIndexMap( override fun getKey(index: Int) = keys.getOrElse(index - keysStartIndex) { null } } - -/** - * Returns a range of indexes which contains at least [extraItemCount] items near - * the first visible item. It is optimized to return the same range for small changes in the - * firstVisibleItem value so we do not regenerate the map on each scroll. - */ -private fun calculateNearestItemsRange( - firstVisibleItem: Int, - slidingWindowSize: Int, - extraItemCount: Int -): IntRange { - val slidingWindowStart = slidingWindowSize * (firstVisibleItem / slidingWindowSize) - - val start = maxOf(slidingWindowStart - extraItemCount, 0) - val end = slidingWindowStart + slidingWindowSize + extraItemCount - return start until end -}
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt index fad74679e0a..2a8bb4582b8 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt @@ -103,6 +103,8 @@ internal class LazyLayoutMeasureScopeImpl internal constructor( private val timeTracker: LazyLayoutPrefetchState.AverageTimeTracker? ) : LazyLayoutMeasureScope, MeasureScope by subcomposeMeasureScope { + private val itemProvider = itemContentFactory.itemProvider() + /** * A cache of the previously composed items. It allows us to support [get] * re-executions with the same index during the same measure pass. @@ -114,8 +116,9 @@ internal class LazyLayoutMeasureScopeImpl internal constructor( return if (cachedPlaceable != null) { cachedPlaceable } else { - val key = itemContentFactory.itemProvider().getKey(index) - val itemContent = itemContentFactory.getContent(index, key) + val key = itemProvider.getKey(index) + val contentType = itemProvider.getContentType(index) + val itemContent = itemContentFactory.getContent(index, key, contentType) val measurables = trackComposition { subcomposeMeasureScope.subcompose(key, itemContent) } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutNearestRangeState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutNearestRangeState.kt new file mode 100644 index 00000000000..69766ea67ca --- /dev/null +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutNearestRangeState.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.lazy.layout + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy + +internal class LazyLayoutNearestRangeState( + firstVisibleItem: Int, + private val slidingWindowSize: Int, + private val extraItemCount: Int +) : State<IntRange> { + + override var value: IntRange by mutableStateOf( + calculateNearestItemsRange(firstVisibleItem, slidingWindowSize, extraItemCount), + structuralEqualityPolicy() + ) + private set + + private var lastFirstVisibleItem = firstVisibleItem + + fun update(firstVisibleItem: Int) { + if (firstVisibleItem != lastFirstVisibleItem) { + lastFirstVisibleItem = firstVisibleItem + value = calculateNearestItemsRange(firstVisibleItem, slidingWindowSize, extraItemCount) + } + } + + private companion object { + /** + * Returns a range of indexes which contains at least [extraItemCount] items near + * the first visible item. It is optimized to return the same range for small changes in the + * firstVisibleItem value so we do not regenerate the map on each scroll. + */ + private fun calculateNearestItemsRange( + firstVisibleItem: Int, + slidingWindowSize: Int, + extraItemCount: Int + ): IntRange { + val slidingWindowStart = slidingWindowSize * (firstVisibleItem / slidingWindowSize) + + val start = maxOf(slidingWindowStart - extraItemCount, 0) + val end = slidingWindowStart + slidingWindowSize + extraItemCount + return start until end + } + } +} diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt index 9a470179d5f..1184ff7a798 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt @@ -18,9 +18,6 @@ package androidx.compose.foundation.lazy.layout import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.unit.Constraints /** @@ -29,7 +26,7 @@ import androidx.compose.ui.unit.Constraints @ExperimentalFoundationApi @Stable class LazyLayoutPrefetchState { - internal var prefetcher: Prefetcher? by mutableStateOf(null) + internal var prefetcher: Prefetcher? = null /** * Schedules precomposition and premeasure for the new item. diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemantics.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemantics.kt index a81ca87c4e2..b7e1ca9dbb6 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemantics.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemantics.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch @Suppress("ComposableModifierFactory") @Composable internal fun Modifier.lazyLayoutSemantics( - itemProvider: LazyLayoutItemProvider, + itemProviderLambda: () -> LazyLayoutItemProvider, state: LazyLayoutSemanticState, orientation: Orientation, userScrollEnabled: Boolean, @@ -48,13 +48,14 @@ internal fun Modifier.lazyLayoutSemantics( val coroutineScope = rememberCoroutineScope() return this.then( remember( - itemProvider, + itemProviderLambda, state, orientation, userScrollEnabled ) { val isVertical = orientation == Orientation.Vertical val indexForKeyMapping: (Any) -> Int = { needle -> + val itemProvider = itemProviderLambda() var result = -1 for (index in 0 until itemProvider.itemCount) { if (itemProvider.getKey(index) == needle) { @@ -74,6 +75,7 @@ internal fun Modifier.lazyLayoutSemantics( state.currentPosition }, maxValue = { + val itemProvider = itemProviderLambda() if (state.canScrollForward) { // If we can scroll further, we don't know the end yet, // but it's upper bounded by #items + 1 @@ -105,6 +107,7 @@ internal fun Modifier.lazyLayoutSemantics( val scrollToIndexAction: ((Int) -> Boolean)? = if (userScrollEnabled) { { index -> + val itemProvider = itemProviderLambda() require(index >= 0 && index < itemProvider.itemCount) { "Can't scroll to index $index, it is out of " + "bounds [0, ${itemProvider.itemCount})" diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt index 79423ba3422..50ae912f34b 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt @@ -63,10 +63,10 @@ internal fun LazyStaggeredGrid( ) { val overscrollEffect = ScrollableDefaults.overscrollEffect() - val itemProvider = rememberStaggeredGridItemProvider(state, content) + val itemProviderLambda = rememberStaggeredGridItemProviderLambda(state, content) val measurePolicy = rememberStaggeredGridMeasurePolicy( state, - itemProvider, + itemProviderLambda, contentPadding, reverseLayout, orientation, @@ -76,14 +76,14 @@ internal fun LazyStaggeredGrid( ) val semanticState = rememberLazyStaggeredGridSemanticState(state, reverseLayout) - ScrollPositionUpdater(itemProvider, state) + ScrollPositionUpdater(itemProviderLambda, state) LazyLayout( modifier = modifier .then(state.remeasurementModifier) .then(state.awaitLayoutModifier) .lazyLayoutSemantics( - itemProvider = itemProvider, + itemProviderLambda = itemProviderLambda, state = semanticState, orientation = orientation, userScrollEnabled = userScrollEnabled, @@ -110,7 +110,7 @@ internal fun LazyStaggeredGrid( enabled = userScrollEnabled ), prefetchState = state.prefetchState, - itemProvider = itemProvider, + itemProvider = itemProviderLambda, measurePolicy = measurePolicy ) } @@ -119,9 +119,10 @@ internal fun LazyStaggeredGrid( @OptIn(ExperimentalFoundationApi::class) @Composable private fun ScrollPositionUpdater( - itemProvider: LazyLayoutItemProvider, + itemProviderLambda: () -> LazyLayoutItemProvider, state: LazyStaggeredGridState ) { + val itemProvider = itemProviderLambda() if (itemProvider.itemCount > 0) { state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt index f9dec0d82b5..4c368126f1c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt @@ -41,8 +41,8 @@ internal class LazyStaggeredGridItemPlacementAnimator { // stored to not allocate it every pass. private val movingAwayKeys = LinkedHashSet<Any>() - private val movingInFromStartBound = mutableListOf<LazyStaggeredGridPositionedItem>() - private val movingInFromEndBound = mutableListOf<LazyStaggeredGridPositionedItem>() + private val movingInFromStartBound = mutableListOf<LazyStaggeredGridMeasuredItem>() + private val movingInFromEndBound = mutableListOf<LazyStaggeredGridMeasuredItem>() private val movingAwayToStartBound = mutableListOf<LazyStaggeredGridMeasuredItem>() private val movingAwayToEndBound = mutableListOf<LazyStaggeredGridMeasuredItem>() @@ -55,7 +55,7 @@ internal class LazyStaggeredGridItemPlacementAnimator { consumedScroll: Int, layoutWidth: Int, layoutHeight: Int, - positionedItems: MutableList<LazyStaggeredGridPositionedItem>, + positionedItems: MutableList<LazyStaggeredGridMeasuredItem>, itemProvider: LazyStaggeredGridMeasureProvider, isVertical: Boolean, laneCount: Int @@ -185,10 +185,9 @@ internal class LazyStaggeredGridItemPlacementAnimator { val mainAxisOffset = 0 - accumulatedOffsetPerLane[item.lane] val itemInfo = keyToItemInfoMap.getValue(item.key) - val positionedItem = - item.position(mainAxisOffset, itemInfo.crossAxisOffset, mainAxisLayoutSize) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + item.position(mainAxisOffset, itemInfo.crossAxisOffset, mainAxisLayoutSize) + positionedItems.add(item) + startAnimationsIfNeeded(item) } accumulatedOffsetPerLane.fill(0) } @@ -199,10 +198,9 @@ internal class LazyStaggeredGridItemPlacementAnimator { accumulatedOffsetPerLane[item.lane] += item.mainAxisSize val itemInfo = keyToItemInfoMap.getValue(item.key) - val positionedItem = - item.position(mainAxisOffset, itemInfo.crossAxisOffset, mainAxisLayoutSize) - positionedItems.add(positionedItem) - startAnimationsIfNeeded(positionedItem) + item.position(mainAxisOffset, itemInfo.crossAxisOffset, mainAxisLayoutSize) + positionedItems.add(item) + startAnimationsIfNeeded(item) } } @@ -224,7 +222,7 @@ internal class LazyStaggeredGridItemPlacementAnimator { } private fun initializeNode( - item: LazyStaggeredGridPositionedItem, + item: LazyStaggeredGridMeasuredItem, mainAxisOffset: Int ) { val firstPlaceableOffset = item.offset @@ -243,7 +241,7 @@ internal class LazyStaggeredGridItemPlacementAnimator { } } - private fun startAnimationsIfNeeded(item: LazyStaggeredGridPositionedItem) { + private fun startAnimationsIfNeeded(item: LazyStaggeredGridMeasuredItem) { item.forEachNode { node -> val newTarget = item.offset val currentTarget = node.rawOffset @@ -258,13 +256,13 @@ internal class LazyStaggeredGridItemPlacementAnimator { private val Any?.node get() = this as? LazyLayoutAnimateItemModifierNode - private val LazyStaggeredGridPositionedItem.hasAnimations: Boolean + private val LazyStaggeredGridMeasuredItem.hasAnimations: Boolean get() { forEachNode { return true } return false } - private inline fun LazyStaggeredGridPositionedItem.forEachNode( + private inline fun LazyStaggeredGridMeasuredItem.forEachNode( block: (LazyLayoutAnimateItemModifierNode) -> Unit ) { repeat(placeablesCount) { index -> diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt index b76423c5317..c1569e316bd 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemProvider.kt @@ -20,10 +20,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem -import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMapState +import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMap import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -34,53 +33,67 @@ internal interface LazyStaggeredGridItemProvider : LazyLayoutItemProvider { val keyIndexMap: LazyLayoutKeyIndexMap } +@OptIn(ExperimentalFoundationApi::class) @Composable -internal fun rememberStaggeredGridItemProvider( +internal fun rememberStaggeredGridItemProviderLambda( state: LazyStaggeredGridState, content: LazyStaggeredGridScope.() -> Unit, -): LazyStaggeredGridItemProvider { +): () -> LazyStaggeredGridItemProvider { val latestContent = rememberUpdatedState(content) return remember(state) { - LazyStaggeredGridItemProviderImpl( - state, - { latestContent.value }, - ) + val intervalContentState = derivedStateOf(referentialEqualityPolicy()) { + LazyStaggeredGridIntervalContent(latestContent.value) + } + val itemProviderState = derivedStateOf(referentialEqualityPolicy()) { + val intervalContent = intervalContentState.value + val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent) + LazyStaggeredGridItemProviderImpl( + state = state, + intervalContent = intervalContent, + keyIndexMap = map + ) + } + itemProviderState::value } } @OptIn(ExperimentalFoundationApi::class) private class LazyStaggeredGridItemProviderImpl( private val state: LazyStaggeredGridState, - private val latestContent: () -> (LazyStaggeredGridScope.() -> Unit) + private val intervalContent: LazyStaggeredGridIntervalContent, + override val keyIndexMap: LazyLayoutKeyIndexMap, ) : LazyStaggeredGridItemProvider { - private val staggeredGridContent by derivedStateOf(referentialEqualityPolicy()) { - LazyStaggeredGridIntervalContent(latestContent()) - } - - override val keyIndexMap: LazyLayoutKeyIndexMap by NearestRangeKeyIndexMapState( - firstVisibleItemIndex = { state.firstVisibleItemIndex }, - slidingWindowSize = { 90 }, - extraItemCount = { 200 }, - content = { staggeredGridContent } - ) - override val itemCount: Int get() = staggeredGridContent.itemCount + override val itemCount: Int get() = intervalContent.itemCount override fun getKey(index: Int): Any = - keyIndexMap.getKey(index) ?: staggeredGridContent.getKey(index) + keyIndexMap.getKey(index) ?: intervalContent.getKey(index) override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key) - override fun getContentType(index: Int): Any? = staggeredGridContent.getContentType(index) + override fun getContentType(index: Int): Any? = intervalContent.getContentType(index) @Composable override fun Item(index: Int, key: Any) { LazyLayoutPinnableItem(key, index, state.pinnedItems) { - staggeredGridContent.withInterval(index) { localIndex, content -> + intervalContent.withInterval(index) { localIndex, content -> content.item(LazyStaggeredGridItemScopeImpl, localIndex) } } } - override val spanProvider get() = staggeredGridContent.spanProvider + override val spanProvider get() = intervalContent.spanProvider + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LazyStaggeredGridItemProviderImpl) return false + + // the identity of this class is represented by intervalContent object. + // having equals() allows us to skip items recomposition when intervalContent didn't change + return intervalContent == other.intervalContent + } + + override fun hashCode(): Int { + return intervalContent.hashCode() + } }
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt index 460d75afff0..51ef3b9e5a0 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt @@ -109,7 +109,12 @@ internal fun LazyLayoutMeasureScope.measureStaggeredGrid( val initialItemOffsets: IntArray Snapshot.withoutReadObservation { - val firstVisibleIndices = state.scrollPosition.indices + // ensure scroll position is up to date + val firstVisibleIndices = + state.updateScrollPositionIfTheFirstItemWasMoved( + itemProvider, + state.scrollPosition.indices + ) val firstVisibleOffsets = state.scrollPosition.offsets initialItemIndices = @@ -181,13 +186,20 @@ internal class LazyStaggeredGridMeasureContext( val reverseLayout: Boolean, val mainAxisSpacing: Int, ) { - val measuredItemProvider = LazyStaggeredGridMeasureProvider( + val measuredItemProvider = object : LazyStaggeredGridMeasureProvider( isVertical = isVertical, itemProvider = itemProvider, measureScope = measureScope, resolvedSlots = resolvedSlots, - ) { index, lane, span, key, contentType, placeables -> - LazyStaggeredGridMeasuredItem( + ) { + override fun createItem( + index: Int, + lane: Int, + span: Int, + key: Any, + contentType: Any?, + placeables: List<Placeable> + ) = LazyStaggeredGridMeasuredItem( index = index, key = key, placeables = placeables, @@ -751,13 +763,12 @@ private fun LazyStaggeredGridMeasureContext.measure( extraItemOffset = itemScrollOffsets[0] val extraItemsAfter = calculateExtraItems( position = { - val positionedItem = it.position( + it.position( mainAxis = extraItemOffset, crossAxis = 0, mainAxisLayoutSize = mainAxisLayoutSize ) extraItemOffset += it.sizeWithSpacings - positionedItem }, filter = { itemIndex -> if (itemIndex >= itemCount) { @@ -775,7 +786,7 @@ private fun LazyStaggeredGridMeasureContext.measure( } ) - val positionedItems = mutableListOf<LazyStaggeredGridPositionedItem>() + val positionedItems = mutableListOf<LazyStaggeredGridMeasuredItem>() positionedItems.addAll(extraItemsBefore) positionedItems.addAll(visibleItems) positionedItems.addAll(extraItemsAfter) @@ -830,8 +841,8 @@ private fun LazyStaggeredGridMeasureContext.calculateVisibleItems( measuredItems: Array<ArrayDeque<LazyStaggeredGridMeasuredItem>>, itemScrollOffsets: IntArray, mainAxisLayoutSize: Int, -): List<LazyStaggeredGridPositionedItem> { - val positionedItems = ArrayList<LazyStaggeredGridPositionedItem>( +): List<LazyStaggeredGridMeasuredItem> { + val positionedItems = ArrayList<LazyStaggeredGridMeasuredItem>( measuredItems.sumOf { it.size } ) while (measuredItems.any { it.isNotEmpty() }) { @@ -853,14 +864,12 @@ private fun LazyStaggeredGridMeasureContext.calculateVisibleItems( // nothing to place, ignore spacings continue } - - positionedItems += - item.position( - mainAxis = mainAxisOffset, - crossAxis = crossAxisOffset, - mainAxisLayoutSize = mainAxisLayoutSize, - lane = laneIndex - ) + item.position( + mainAxis = mainAxisOffset, + crossAxis = crossAxisOffset, + mainAxisLayoutSize = mainAxisLayoutSize, + ) + positionedItems += item spanRange.forEach { lane -> itemScrollOffsets[lane] = mainAxisOffset + item.sizeWithSpacings } @@ -870,10 +879,10 @@ private fun LazyStaggeredGridMeasureContext.calculateVisibleItems( @ExperimentalFoundationApi private inline fun LazyStaggeredGridMeasureContext.calculateExtraItems( - position: (LazyStaggeredGridMeasuredItem) -> LazyStaggeredGridPositionedItem, + position: (LazyStaggeredGridMeasuredItem) -> Unit, filter: (itemIndex: Int) -> Boolean -): List<LazyStaggeredGridPositionedItem> { - var result: MutableList<LazyStaggeredGridPositionedItem>? = null +): List<LazyStaggeredGridMeasuredItem> { + var result: MutableList<LazyStaggeredGridMeasuredItem>? = null pinnedItems.fastForEach { index -> if (filter(index)) { @@ -882,7 +891,8 @@ private inline fun LazyStaggeredGridMeasureContext.calculateExtraItems( result = mutableListOf() } val measuredItem = measuredItemProvider.getAndMeasure(index, spanRange) - result?.add(position(measuredItem)) + position(measuredItem) + result?.add(measuredItem) } } @@ -987,12 +997,11 @@ private fun LazyStaggeredGridMeasureContext.findPreviousItemIndex(item: Int, lan laneInfo.findPreviousItemIndex(item, lane) @OptIn(ExperimentalFoundationApi::class) -internal class LazyStaggeredGridMeasureProvider( +internal abstract class LazyStaggeredGridMeasureProvider( private val isVertical: Boolean, private val itemProvider: LazyStaggeredGridItemProvider, private val measureScope: LazyLayoutMeasureScope, - private val resolvedSlots: LazyStaggeredGridSlots, - private val measuredItemFactory: MeasuredItemFactory, + private val resolvedSlots: LazyStaggeredGridSlots ) { private fun childConstraints(requestedSlot: Int, requestedSpan: Int): Constraints { val slotCount = resolvedSlots.sizes.size @@ -1017,10 +1026,10 @@ internal class LazyStaggeredGridMeasureProvider( } fun getAndMeasure(index: Int, span: SpanRange): LazyStaggeredGridMeasuredItem { - val key = keyIndexMap.getKey(index) ?: itemProvider.getKey(index) + val key = itemProvider.getKey(index) val contentType = itemProvider.getContentType(index) val placeables = measureScope.measure(index, childConstraints(span.start, span.size)) - return measuredItemFactory.createItem( + return createItem( index, span.start, span.size, @@ -1030,12 +1039,9 @@ internal class LazyStaggeredGridMeasureProvider( ) } - val keyIndexMap: LazyLayoutKeyIndexMap = itemProvider.keyIndexMap -} + val keyIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyIndexMap -// This interface allows to avoid autoboxing on index param -internal fun interface MeasuredItemFactory { - fun createItem( + abstract fun createItem( index: Int, lane: Int, span: Int, @@ -1046,17 +1052,17 @@ internal fun interface MeasuredItemFactory { } internal class LazyStaggeredGridMeasuredItem( - val index: Int, - val key: Any, + override val index: Int, + override val key: Any, private val placeables: List<Placeable>, - private val isVertical: Boolean, + val isVertical: Boolean, spacing: Int, - val lane: Int, + override val lane: Int, val span: Int, private val beforeContentPadding: Int, private val afterContentPadding: Int, - private val contentType: Any? -) { + override val contentType: Any? +) : LazyStaggeredGridItemInfo { var isVisible = true val placeablesCount: Int get() = placeables.size @@ -1073,63 +1079,40 @@ internal class LazyStaggeredGridMeasuredItem( if (isVertical) it.width else it.height } ?: 0 + private var mainAxisLayoutSize: Int = Unset + private var minMainAxisOffset: Int = 0 + private var maxMainAxisOffset: Int = 0 + + override val size: IntSize = if (isVertical) { + IntSize(crossAxisSize, mainAxisSize) + } else { + IntSize(mainAxisSize, crossAxisSize) + } + override var offset: IntOffset = IntOffset.Zero + private set + fun position( mainAxis: Int, crossAxis: Int, mainAxisLayoutSize: Int, - lane: Int = 0 - ): LazyStaggeredGridPositionedItem = - LazyStaggeredGridPositionedItem( - offset = if (isVertical) { - IntOffset(crossAxis, mainAxis) - } else { - IntOffset(mainAxis, crossAxis) - }, - lane = lane, - index = index, - key = key, - size = if (isVertical) { - IntSize(crossAxisSize, mainAxisSize) - } else { - IntSize(mainAxisSize, crossAxisSize) - }, - placeables = placeables, - isVertical = isVertical, - mainAxisLayoutSize = mainAxisLayoutSize, - minMainAxisOffset = -beforeContentPadding, - maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding, - span = span, - contentType = contentType - ) -} - -internal class LazyStaggeredGridPositionedItem( - override val offset: IntOffset, - override val index: Int, - override val lane: Int, - override val key: Any, - override val size: IntSize, - private val placeables: List<Placeable>, - val isVertical: Boolean, - private val mainAxisLayoutSize: Int, - private val minMainAxisOffset: Int, - private val maxMainAxisOffset: Int, - val span: Int, - override val contentType: Any? -) : LazyStaggeredGridItemInfo { - - val placeablesCount: Int get() = placeables.size - - val mainAxisSize get() = if (isVertical) size.height else size.width + ) { + this.mainAxisLayoutSize = mainAxisLayoutSize + minMainAxisOffset = -beforeContentPadding + maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding + offset = if (isVertical) { + IntOffset(crossAxis, mainAxis) + } else { + IntOffset(mainAxis, crossAxis) + } + } val crossAxisOffset get() = if (isVertical) offset.x else offset.y - fun getParentData(index: Int) = placeables[index].parentData - fun place( scope: Placeable.PlacementScope, context: LazyStaggeredGridMeasureContext ) = with(context) { + require(mainAxisLayoutSize != Unset) { "position() should be called first" } with(scope) { placeables.fastForEachIndexed { index, placeable -> val minOffset = minMainAxisOffset - placeable.mainAxisSize @@ -1170,3 +1153,5 @@ internal class LazyStaggeredGridPositionedItem( super.toString() } } + +private const val Unset = Int.MIN_VALUE diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt index c72c1145bcb..2ef1a74feb4 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasurePolicy.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.unit.constrainWidth @Composable internal fun rememberStaggeredGridMeasurePolicy( state: LazyStaggeredGridState, - itemProvider: LazyStaggeredGridItemProvider, + itemProviderLambda: () -> LazyStaggeredGridItemProvider, contentPadding: PaddingValues, reverseLayout: Boolean, orientation: Orientation, @@ -47,7 +47,7 @@ internal fun rememberStaggeredGridMeasurePolicy( slots: Density.(Constraints) -> LazyStaggeredGridSlots ): LazyLayoutMeasureScope.(Constraints) -> LazyStaggeredGridMeasureResult = remember( state, - itemProvider, + itemProviderLambda, contentPadding, reverseLayout, orientation, @@ -62,15 +62,13 @@ internal fun rememberStaggeredGridMeasurePolicy( ) val resolvedSlots = slots(this, constraints) val isVertical = orientation == Orientation.Vertical + val itemProvider = itemProviderLambda() // setup information for prefetch state.slots = resolvedSlots state.isVertical = isVertical state.spanProvider = itemProvider.spanProvider - // ensure scroll position is up to date - state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - // setup measure val beforeContentPadding = contentPadding.beforePadding( orientation, reverseLayout, layoutDirection diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollPosition.kt index 5d5fe3306f4..7f8809d8d48 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollPosition.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridScrollPosition.kt @@ -18,7 +18,9 @@ package androidx.compose.foundation.lazy.staggeredgrid import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider +import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState import androidx.compose.foundation.lazy.layout.findIndexByKey +import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -30,15 +32,23 @@ internal class LazyStaggeredGridScrollPosition( initialIndices: IntArray, initialOffsets: IntArray, private val fillIndices: (targetIndex: Int, laneCount: Int) -> IntArray -) { - var indices by mutableStateOf(initialIndices) - var offsets by mutableStateOf(initialOffsets) +) : SnapshotMutationPolicy<IntArray> { + var indices by mutableStateOf(initialIndices, this) + private set + var offsets by mutableStateOf(initialOffsets, this) + private set private var hadFirstNotEmptyLayout = false /** The last know key of the item at lowest of [indices] position. */ private var lastKnownFirstItemKey: Any? = null + val nearestRangeState = LazyLayoutNearestRangeState( + initialIndices.minOrNull() ?: 0, + NearestItemsSlidingWindowSize, + NearestItemsExtraItemCount + ) + /** * Updates the current scroll position based on the results of the last measurement. */ @@ -50,6 +60,7 @@ internal class LazyStaggeredGridScrollPosition( lastKnownFirstItemKey = measureResult.visibleItemsInfo .fastFirstOrNull { it.index == firstVisibleIndex } ?.key + nearestRangeState.update(firstVisibleIndex) // we ignore the index and offset from measureResult until we get at least one // measurement with real items. otherwise the initial index and scroll passed to the // state would be lost and overridden with zeros. @@ -79,6 +90,7 @@ internal class LazyStaggeredGridScrollPosition( val newIndices = fillIndices(index, indices.size) val newOffsets = IntArray(newIndices.size) { scrollOffset } update(newIndices, newOffsets) + nearestRangeState.update(index) // clear the stored key as we have a direct request to scroll to [index] position and the // next [updateScrollPositionIfTheFirstItemWasMoved] shouldn't override this. lastKnownFirstItemKey = null @@ -91,27 +103,40 @@ internal class LazyStaggeredGridScrollPosition( * as the first visible one even given that its index has been changed. */ @ExperimentalFoundationApi - fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyLayoutItemProvider) { - Snapshot.withoutReadObservation { - val lastIndex = itemProvider.findIndexByKey( - key = lastKnownFirstItemKey, - lastKnownIndex = indices.getOrNull(0) ?: 0 - ) - if (lastIndex !in indices) { - update( - fillIndices(lastIndex, indices.size), - offsets - ) - } + fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyLayoutItemProvider, + indices: IntArray + ): IntArray { + val newIndex = itemProvider.findIndexByKey( + key = lastKnownFirstItemKey, + lastKnownIndex = indices.getOrNull(0) ?: 0 + ) + return if (newIndex !in indices) { + nearestRangeState.update(newIndex) + val newIndices = fillIndices(newIndex, indices.size) + this.indices = newIndices + newIndices + } else { + indices } } private fun update(indices: IntArray, offsets: IntArray) { - if (!indices.contentEquals(this.indices)) { - this.indices = indices - } - if (!offsets.contentEquals(this.offsets)) { - this.offsets = offsets - } + this.indices = indices + this.offsets = offsets } -}
\ No newline at end of file + + // mutation policy for int arrays + override fun equivalent(a: IntArray, b: IntArray) = a.contentEquals(b) +} + +/** + * We use the idea of sliding window as an optimization, so user can scroll up to this number of + * items until we have to regenerate the key to index map. + */ +private const val NearestItemsSlidingWindowSize = 90 + +/** + * The minimum amount of items near the current first visible item we want to have mapping for. + */ +private const val NearestItemsExtraItemCount = 200 diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt index 84f9df6a64c..f37ea64496b 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridState.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.layout.Remeasurement import androidx.compose.ui.layout.RemeasurementModifier @@ -223,6 +224,8 @@ class LazyStaggeredGridState private constructor( internal val placementAnimator = LazyStaggeredGridItemPlacementAnimator() + internal val nearestRange: IntRange by scrollPosition.nearestRangeState + /** * Call this function to take control of scrolling and gain the ability to send scroll events * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be @@ -331,9 +334,11 @@ class LazyStaggeredGridState private constructor( /** * Maintain scroll position for item based on custom key if its index has changed. */ - internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyLayoutItemProvider) { - scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider) - } + internal fun updateScrollPositionIfTheFirstItemWasMoved( + itemProvider: LazyLayoutItemProvider, + firstItemIndex: IntArray = Snapshot.withoutReadObservation { scrollPosition.indices } + ): IntArray = + scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex) override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt index d42eaed5b47..9c4c19b6d62 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt @@ -25,8 +25,6 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.NearestItemsExtraItemCount -import androidx.compose.foundation.lazy.NearestItemsSlidingWindowSize import androidx.compose.foundation.lazy.layout.IntervalList import androidx.compose.foundation.lazy.layout.LazyLayout import androidx.compose.foundation.lazy.layout.LazyLayoutIntervalContent @@ -34,15 +32,14 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem import androidx.compose.foundation.lazy.layout.MutableIntervalList -import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMapState +import androidx.compose.foundation.lazy.layout.NearestRangeKeyIndexMap import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics import androidx.compose.foundation.overscroll import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue +import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -98,7 +95,7 @@ internal fun Pager( val overscrollEffect = ScrollableDefaults.overscrollEffect() - val pagerItemProvider = rememberPagerItemProvider( + val pagerItemProvider = rememberPagerItemProviderLambda( state = state, pageContent = pageContent, key = key @@ -114,7 +111,7 @@ internal fun Pager( pageSize = pageSize, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - itemProvider = pagerItemProvider, + itemProviderLambda = pagerItemProvider, pageCount = { state.pageCount }, ) @@ -130,7 +127,6 @@ internal fun Pager( val semanticState = rememberPagerSemanticState( state, - pagerItemProvider, reverseLayout, orientation == Orientation.Vertical ) @@ -141,7 +137,7 @@ internal fun Pager( .then(state.awaitLayoutModifier) .then(pagerSemantics) .lazyLayoutSemantics( - itemProvider = pagerItemProvider, + itemProviderLambda = pagerItemProvider, state = semanticState, orientation = orientation, userScrollEnabled = userScrollEnabled, @@ -178,38 +174,42 @@ internal fun Pager( @ExperimentalFoundationApi internal class PagerLazyLayoutItemProvider( - val state: PagerState, - latestContent: () -> (@Composable PagerScope.(page: Int) -> Unit), - key: ((index: Int) -> Any)?, - pageCount: () -> Int + private val state: PagerState, + private val intervalContent: LazyLayoutIntervalContent<PagerIntervalContent>, + private val keyIndexMap: LazyLayoutKeyIndexMap, ) : LazyLayoutItemProvider { - private val pagerContent by derivedStateOf(structuralEqualityPolicy()) { - PagerLayoutIntervalContent(latestContent(), key = key, pageCount = pageCount()) - } - private val keyToIndexMap: LazyLayoutKeyIndexMap by NearestRangeKeyIndexMapState( - firstVisibleItemIndex = { state.firstVisiblePage }, - slidingWindowSize = { NearestItemsSlidingWindowSize }, - extraItemCount = { NearestItemsExtraItemCount }, - content = { pagerContent } - ) private val pagerScopeImpl = PagerScopeImpl override val itemCount: Int - get() = pagerContent.itemCount + get() = intervalContent.itemCount @Composable override fun Item(index: Int, key: Any) { LazyLayoutPinnableItem(key, index, state.pinnedPages) { - pagerContent.withInterval(index) { localIndex, content -> + intervalContent.withInterval(index) { localIndex, content -> content.item(pagerScopeImpl, localIndex) } } } - override fun getKey(index: Int): Any = keyToIndexMap.getKey(index) ?: pagerContent.getKey(index) + override fun getKey(index: Int): Any = + keyIndexMap.getKey(index) ?: intervalContent.getKey(index) - override fun getIndex(key: Any): Int = keyToIndexMap.getIndex(key) + override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PagerLazyLayoutItemProvider) return false + + // the identity of this class is represented by intervalContent object. + // having equals() allows us to skip items recomposition when intervalContent didn't change + return intervalContent == other.intervalContent + } + + override fun hashCode(): Int { + return intervalContent.hashCode() + } } @OptIn(ExperimentalFoundationApi::class) @@ -232,20 +232,27 @@ internal class PagerIntervalContent( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun rememberPagerItemProvider( +private fun rememberPagerItemProviderLambda( state: PagerState, pageContent: @Composable PagerScope.(page: Int) -> Unit, key: ((index: Int) -> Any)?, pageCount: () -> Int -): PagerLazyLayoutItemProvider { +): () -> PagerLazyLayoutItemProvider { val latestContent = rememberUpdatedState(pageContent) return remember(state, latestContent, key, pageCount) { - PagerLazyLayoutItemProvider( - state = state, - latestContent = { latestContent.value }, - key = key, - pageCount = pageCount - ) + val intervalContentState = derivedStateOf(referentialEqualityPolicy()) { + PagerLayoutIntervalContent(latestContent.value, key, pageCount()) + } + val itemProviderState = derivedStateOf(referentialEqualityPolicy()) { + val intervalContent = intervalContentState.value + val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent) + PagerLazyLayoutItemProvider( + state = state, + intervalContent = intervalContent, + keyIndexMap = map + ) + } + itemProviderState::value } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt index 8543acdf57f..8ad0a3ec072 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt @@ -16,86 +16,103 @@ package androidx.compose.foundation.pager +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.Alignment import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +@OptIn(ExperimentalFoundationApi::class) internal class MeasuredPage( - val index: Int, + override val index: Int, val size: Int, - val placeables: List<Placeable>, - val visualOffset: IntOffset, + private val placeables: List<Placeable>, + private val visualOffset: IntOffset, val key: Any, - val orientation: Orientation, - val horizontalAlignment: Alignment.Horizontal?, - val verticalAlignment: Alignment.Vertical?, - val layoutDirection: LayoutDirection, - val reverseLayout: Boolean, - val beforeContentPadding: Int, - val afterContentPadding: Int, -) { + orientation: Orientation, + private val horizontalAlignment: Alignment.Horizontal?, + private val verticalAlignment: Alignment.Vertical?, + private val layoutDirection: LayoutDirection, + private val reverseLayout: Boolean +) : PageInfo { + + private val isVertical = orientation == Orientation.Vertical val crossAxisSize: Int + // optimized for storing x and y offsets for each placeable one by one. + // array's size == placeables.size * 2, first we store x, then y. + private val placeableOffsets: IntArray + init { var maxCrossAxis = 0 placeables.fastForEach { maxCrossAxis = maxOf( maxCrossAxis, - if (orientation != Orientation.Vertical) it.height else it.width + if (!isVertical) it.height else it.width ) } crossAxisSize = maxCrossAxis + placeableOffsets = IntArray(placeables.size * 2) } + override var offset: Int = 0 + private set + + private var mainAxisLayoutSize: Int = Unset + fun position( offset: Int, layoutWidth: Int, layoutHeight: Int - ): PositionedPage { - val wrappers = mutableListOf<PagerPlaceableWrapper>() - val mainAxisLayoutSize = - if (orientation == Orientation.Vertical) layoutHeight else layoutWidth - var mainAxisOffset = if (reverseLayout) { - mainAxisLayoutSize - offset - size - } else { - offset + ) { + this.offset = offset + mainAxisLayoutSize = + if (isVertical) layoutHeight else layoutWidth + var mainAxisOffset = offset + placeables.fastForEachIndexed { index, placeable -> + val indexInArray = index * 2 + if (isVertical) { + placeableOffsets[indexInArray] = requireNotNull(horizontalAlignment) + .align(placeable.width, layoutWidth, layoutDirection) + placeableOffsets[indexInArray + 1] = mainAxisOffset + mainAxisOffset += placeable.height + } else { + placeableOffsets[indexInArray] = mainAxisOffset + placeableOffsets[indexInArray + 1] = requireNotNull(verticalAlignment) + .align(placeable.height, layoutHeight) + mainAxisOffset += placeable.width + } } - var index = if (reverseLayout) placeables.lastIndex else 0 - while (if (reverseLayout) index >= 0 else index < placeables.size) { - val it = placeables[index] - val addIndex = if (reverseLayout) 0 else wrappers.size - val placeableOffset = if (orientation == Orientation.Vertical) { - val x = requireNotNull(horizontalAlignment) - .align(it.width, layoutWidth, layoutDirection) - IntOffset(x, mainAxisOffset) + } + + fun place(scope: Placeable.PlacementScope) = with(scope) { + require(mainAxisLayoutSize != Unset) { "position() should be called first" } + repeat(placeables.size) { index -> + val placeable = placeables[index] + var offset = getOffset(index) + if (reverseLayout) { + offset = offset.copy { mainAxisOffset -> + mainAxisLayoutSize - mainAxisOffset - placeable.mainAxisSize + } + } + offset += visualOffset + if (isVertical) { + placeable.placeWithLayer(offset) } else { - val y = requireNotNull(verticalAlignment).align(it.height, layoutHeight) - IntOffset(mainAxisOffset, y) + placeable.placeRelativeWithLayer(offset) } - mainAxisOffset += if (orientation == Orientation.Vertical) it.height else it.width - wrappers.add( - addIndex, - PagerPlaceableWrapper(placeableOffset, it, placeables[index].parentData) - ) - if (reverseLayout) index-- else index++ } - return PositionedPage( - offset = offset, - index = this.index, - key = key, - orientation = orientation, - wrappers = wrappers, - visualOffset = visualOffset, - ) } + + private fun getOffset(index: Int) = + IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1]) + private val Placeable.mainAxisSize get() = if (isVertical) height else width + private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset = + IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y) } -internal class PagerPlaceableWrapper( - val offset: IntOffset, - val placeable: Placeable, - val parentData: Any? -)
\ No newline at end of file +private const val Unset = Int.MIN_VALUE diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt index 4c6eb096ab9..90e0f3db618 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt @@ -119,7 +119,7 @@ internal fun LazyLayoutMeasureScope.measurePager( } // this will contain all the measured pages representing the visible pages - val visiblePages = mutableListOf<MeasuredPage>() + val visiblePages = ArrayDeque<MeasuredPage>() // define min and max offsets val minOffset = -beforeContentPadding + if (spaceBetweenPages < 0) spaceBetweenPages else 0 @@ -146,8 +146,6 @@ internal fun LazyLayoutMeasureScope.measurePager( orientation = orientation, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, pageAvailableSize = pageAvailableSize @@ -194,8 +192,6 @@ internal fun LazyLayoutMeasureScope.measurePager( orientation = orientation, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, pageAvailableSize = pageAvailableSize @@ -232,8 +228,6 @@ internal fun LazyLayoutMeasureScope.measurePager( orientation = orientation, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, pageAvailableSize = pageAvailableSize @@ -298,8 +292,6 @@ internal fun LazyLayoutMeasureScope.measurePager( orientation = orientation, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, pageAvailableSize = pageAvailableSize @@ -326,8 +318,6 @@ internal fun LazyLayoutMeasureScope.measurePager( orientation = orientation, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, pageAvailableSize = pageAvailableSize @@ -478,8 +468,6 @@ private fun LazyLayoutMeasureScope.getAndMeasure( orientation: Orientation, horizontalAlignment: Alignment.Horizontal?, verticalAlignment: Alignment.Vertical?, - afterContentPadding: Int, - beforeContentPadding: Int, layoutDirection: LayoutDirection, reverseLayout: Boolean, pageAvailableSize: Int @@ -493,8 +481,6 @@ private fun LazyLayoutMeasureScope.getAndMeasure( visualOffset = visualPageOffset, horizontalAlignment = horizontalAlignment, verticalAlignment = verticalAlignment, - afterContentPadding = afterContentPadding, - beforeContentPadding = beforeContentPadding, layoutDirection = layoutDirection, reverseLayout = reverseLayout, size = pageAvailableSize, @@ -518,7 +504,7 @@ private fun LazyLayoutMeasureScope.calculatePagesOffsets( density: Density, spaceBetweenPages: Int, pageAvailableSize: Int -): MutableList<PositionedPage> { +): MutableList<MeasuredPage> { val pageSizeWithSpacing = (pageAvailableSize + spaceBetweenPages) val mainAxisLayoutSize = if (orientation == Orientation.Vertical) layoutHeight else layoutWidth val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset) @@ -526,7 +512,7 @@ private fun LazyLayoutMeasureScope.calculatePagesOffsets( check(pagesScrollOffset == 0) } val positionedPages = - ArrayList<PositionedPage>(pages.size + extraPagesBefore.size + extraPagesAfter.size) + ArrayList<MeasuredPage>(pages.size + extraPagesBefore.size + extraPagesAfter.size) if (hasSpareSpace) { require(extraPagesBefore.isEmpty() && extraPagesAfter.isEmpty()) @@ -560,23 +546,27 @@ private fun LazyLayoutMeasureScope.calculatePagesOffsets( } else { absoluteOffset } - positionedPages.add(page.position(relativeOffset, layoutWidth, layoutHeight)) + page.position(relativeOffset, layoutWidth, layoutHeight) + positionedPages.add(page) } } else { var currentMainAxis = pagesScrollOffset extraPagesBefore.fastForEach { currentMainAxis -= pageSizeWithSpacing - positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedPages.add(it) } currentMainAxis = pagesScrollOffset pages.fastForEach { - positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedPages.add(it) currentMainAxis += pageSizeWithSpacing } extraPagesAfter.fastForEach { - positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight)) + it.position(currentMainAxis, layoutWidth, layoutHeight) + positionedPages.add(it) currentMainAxis += pageSizeWithSpacing } } diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt index 2049609192c..d803fb0b451 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt @@ -40,7 +40,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalFoundationApi::class) @Composable internal fun rememberPagerMeasurePolicy( - itemProvider: PagerLazyLayoutItemProvider, + itemProviderLambda: () -> PagerLazyLayoutItemProvider, state: PagerState, contentPadding: PaddingValues, reverseLayout: Boolean, @@ -150,6 +150,7 @@ internal fun rememberPagerMeasurePolicy( } } + val itemProvider = itemProviderLambda() val pinnedPages = itemProvider.calculateLazyLayoutPinnedIndices( pinnedItemList = state.pinnedPages, beyondBoundsInfo = state.beyondBoundsInfo diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt index cecb3717f04..5597496a395 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt @@ -17,10 +17,10 @@ package androidx.compose.foundation.pager import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot /** * Contains the current scroll position represented by the first visible page and the first @@ -42,6 +42,12 @@ internal class PagerScrollPosition( /** The last know key of the page at [firstVisiblePage] position. */ private var lastKnownFirstPageKey: Any? = null + val nearestRangeState = LazyLayoutNearestRangeState( + initialPage, + NearestItemsSlidingWindowSize, + NearestItemsExtraItemCount + ) + /** * Updates the current scroll position based on the results of the last measurement. */ @@ -55,16 +61,12 @@ internal class PagerScrollPosition( val scrollOffset = measureResult.firstVisiblePageOffset check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" } - Snapshot.withoutReadObservation { - update( - measureResult.firstVisiblePage?.index ?: 0, - scrollOffset - ) - measureResult.closestPageToSnapPosition?.index?.let { - if (it != this.currentPage) { - this.currentPage = it - } - } + update( + measureResult.firstVisiblePage?.index ?: 0, + scrollOffset + ) + measureResult.closestPageToSnapPosition?.index?.let { + this.currentPage = it } } } @@ -89,11 +91,19 @@ internal class PagerScrollPosition( private fun update(index: Int, scrollOffset: Int) { require(index >= 0f) { "Index should be non-negative ($index)" } - if (index != this.firstVisiblePage) { - this.firstVisiblePage = index - } - if (scrollOffset != this.scrollOffset) { - this.scrollOffset = scrollOffset - } + this.firstVisiblePage = index + nearestRangeState.update(index) + this.scrollOffset = scrollOffset } -}
\ No newline at end of file +} + +/** + * We use the idea of sliding window as an optimization, so user can scroll up to this number of + * items until we have to regenerate the key to index map. + */ +internal const val NearestItemsSlidingWindowSize = 30 + +/** + * The minimum amount of items near the current first visible item we want to have mapping for. + */ +internal const val NearestItemsExtraItemCount = 100 diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerSemantics.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerSemantics.kt index 82d10fdf645..ed274f670cb 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerSemantics.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerSemantics.kt @@ -17,7 +17,6 @@ package androidx.compose.foundation.pager import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider import androidx.compose.foundation.lazy.layout.LazyLayoutSemanticState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -26,11 +25,10 @@ import androidx.compose.runtime.remember @Composable internal fun rememberPagerSemanticState( state: PagerState, - itemProvider: LazyLayoutItemProvider, reverseScrolling: Boolean, isVertical: Boolean ): LazyLayoutSemanticState { - return remember(state, itemProvider, reverseScrolling, isVertical) { + return remember(state, reverseScrolling, isVertical) { LazyLayoutSemanticState(state, isVertical) } }
\ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt index ea799c26ccf..6c99d848427 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt @@ -240,7 +240,7 @@ abstract class PagerState( internal val pageSize: Int get() = pagerLayoutInfoState.value.pageSize - internal var density: Density by mutableStateOf(UnitDensity) + internal var density: Density = UnitDensity private val visiblePages: List<PageInfo> get() = pagerLayoutInfoState.value.visiblePagesInfo @@ -397,13 +397,15 @@ abstract class PagerState( /** * Constraints passed to the prefetcher for premeasuring the prefetched items. */ - internal var premeasureConstraints by mutableStateOf(Constraints()) + internal var premeasureConstraints = Constraints() /** * Stores currently pinned pages which are always composed, used by for beyond bound pages. */ internal val pinnedPages = LazyLayoutPinnedItemList() + internal val nearestRange: IntRange by scrollPosition.nearestRangeState + /** * Scroll (jump immediately) to a given [page]. * diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PositionedPage.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PositionedPage.kt deleted file mode 100644 index 57ce77f2c02..00000000000 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PositionedPage.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation.pager - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.unit.IntOffset - -@OptIn(ExperimentalFoundationApi::class) -internal class PositionedPage( - override val index: Int, - override val offset: Int, - val key: Any, - val orientation: Orientation, - val wrappers: MutableList<PagerPlaceableWrapper>, - val visualOffset: IntOffset -) : PageInfo { - fun place(scope: Placeable.PlacementScope) = with(scope) { - repeat(wrappers.size) { index -> - val placeable = wrappers[index].placeable - val offset = wrappers[index].offset - if (orientation == Orientation.Vertical) { - placeable.placeWithLayer(offset + visualOffset) - } else { - placeable.placeRelativeWithLayer(offset + visualOffset) - } - } - } -}
\ No newline at end of file diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt index 06356b3cc5b..4ba37d0b49f 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt @@ -743,7 +743,7 @@ internal class CompositionImpl( // Record derived state dependency mapping if (value is DerivedState<*>) { derivedStates.removeScope(value) - for (dependency in value.dependencies) { + for (dependency in value.currentRecord.dependencies) { // skip over empty objects from dependency array if (dependency == null) break derivedStates.add(dependency, value) diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt index 538ada1a551..10bc8c43b4c 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt @@ -39,17 +39,9 @@ import kotlin.math.min */ internal interface DerivedState<T> : State<T> { /** - * The value of the derived state retrieved without triggering a notification to read observers. + * Provides a current [Record]. */ - val currentValue: T - - /** - * A list of the dependencies used to produce [value] or [currentValue]. - * - * The [dependencies] list can be used to determine when a [StateObject] appears in the apply - * observer set, if the state could affect value of this derived state. - */ - val dependencies: Array<Any?> + val currentRecord: Record<T> /** * Mutation policy that controls how changes are handled after state dependencies update. @@ -57,6 +49,21 @@ internal interface DerivedState<T> : State<T> { * produced and it is up to observer to invalidate it correctly. */ val policy: SnapshotMutationPolicy<T>? + + interface Record<T> { + /** + * The value of the derived state retrieved without triggering a notification to read observers. + */ + val currentValue: T + + /** + * A list of the dependencies used to produce [value] or [currentValue]. + * + * The [dependencies] list can be used to determine when a [StateObject] appears in the apply + * observer set, if the state could affect value of this derived state. + */ + val dependencies: Array<Any?> + } } private val calculationBlockNestedLevel = SnapshotThreadLocal<Int>() @@ -67,7 +74,7 @@ private class DerivedSnapshotState<T>( ) : StateObject, DerivedState<T> { private var first: ResultRecord<T> = ResultRecord() - class ResultRecord<T> : StateRecord() { + class ResultRecord<T> : StateRecord(), DerivedState.Record<T> { companion object { val Unset = Any() } @@ -75,14 +82,14 @@ private class DerivedSnapshotState<T>( var validSnapshotId: Int = 0 var validSnapshotWriteCount: Int = 0 - var dependencies: IdentityArrayMap<StateObject, Int>? = null + var _dependencies: IdentityArrayMap<StateObject, Int>? = null var result: Any? = Unset var resultHash: Int = 0 override fun assign(value: StateRecord) { @Suppress("UNCHECKED_CAST") val other = value as ResultRecord<T> - dependencies = other.dependencies + _dependencies = other._dependencies result = other.result resultHash = other.resultHash } @@ -108,7 +115,7 @@ private class DerivedSnapshotState<T>( fun readableHash(derivedState: DerivedState<*>, snapshot: Snapshot): Int { var hash = 7 - val dependencies = sync { dependencies } + val dependencies = sync { _dependencies } if (dependencies != null) { notifyObservers(derivedState) { dependencies.forEach { stateObject, readLevel -> @@ -134,6 +141,13 @@ private class DerivedSnapshotState<T>( } return hash } + + override val currentValue: T + @Suppress("UNCHECKED_CAST") + get() = result as T + + override val dependencies: Array<Any?> + get() = _dependencies?.keys ?: emptyArray() } /** @@ -143,7 +157,6 @@ private class DerivedSnapshotState<T>( * @return latest state record for the derived state. */ fun current(snapshot: Snapshot): StateRecord = - @Suppress("UNCHECKED_CAST") currentRecord(current(first, snapshot), snapshot, false, calculation) private fun currentRecord( @@ -157,7 +170,7 @@ private class DerivedSnapshotState<T>( // for correct invalidation later if (forceDependencyReads) { notifyObservers(this) { - val dependencies = readable.dependencies + val dependencies = readable._dependencies val invalidationNestedLevel = calculationBlockNestedLevel.get() ?: 0 dependencies?.forEach { dependency, nestedLevel -> calculationBlockNestedLevel.set(nestedLevel + invalidationNestedLevel) @@ -201,14 +214,14 @@ private class DerivedSnapshotState<T>( @Suppress("UNCHECKED_CAST") policy?.equivalent(result, readable.result as T) == true ) { - readable.dependencies = newDependencies + readable._dependencies = newDependencies readable.resultHash = readable.readableHash(this, currentSnapshot) readable.validSnapshotId = snapshot.id readable.validSnapshotWriteCount = snapshot.writeCount readable } else { val writable = first.newWritableRecord(this, currentSnapshot) - writable.dependencies = newDependencies + writable._dependencies = newDependencies writable.resultHash = writable.readableHash(this, currentSnapshot) writable.validSnapshotId = snapshot.id writable.validSnapshotWriteCount = snapshot.writeCount @@ -245,18 +258,11 @@ private class DerivedSnapshotState<T>( } } - override val currentValue: T - get() = first.withCurrent { - @Suppress("UNCHECKED_CAST") - currentRecord(it, Snapshot.current, false, calculation).result as T - } - - override val dependencies: Array<Any?> - get() = first.withCurrent { - val record = currentRecord(it, Snapshot.current, false, calculation) - @Suppress("UNCHECKED_CAST") - record.dependencies?.keys ?: emptyArray() + override val currentRecord: DerivedState.Record<T> get() { + return first.withCurrent { + currentRecord(it, Snapshot.current, false, calculation) } + } override fun toString(): String = first.withCurrent { "DerivedState(value=${displayValue()})@${hashCode()}" diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt index 38a8945a0f8..61f8d709135 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt @@ -269,7 +269,7 @@ internal class RecomposeScopeImpl( val tracked = trackedDependencies ?: IdentityArrayMap<DerivedState<*>, Any?>().also { trackedDependencies = it } - tracked[instance] = instance.currentValue + tracked[instance] = instance.currentRecord.currentValue } return false @@ -299,7 +299,7 @@ internal class RecomposeScopeImpl( @Suppress("UNCHECKED_CAST") it as DerivedState<Any?> val policy = it.policy ?: structuralEqualityPolicy() - policy.equivalent(it.currentValue, trackedDependencies[it]) + policy.equivalent(it.currentRecord.currentValue, trackedDependencies[it]) } } ) diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt index 43a64f55607..9cfcca0ed58 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt @@ -380,7 +380,8 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit /** * Counter for skipping reads inside derived states. If count is > 0, read happens inside * a derived state. - * Reads for derived states are captured separately through [DerivedState.dependencies]. + * Reads for derived states are captured separately through + * [DerivedState.Record.dependencies]. */ private var deriveStateScopeCount = 0 @@ -423,10 +424,11 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit val previousToken = recordedValues.add(value, currentToken) if (value is DerivedState<*> && previousToken != currentToken) { + val record = value.currentRecord // re-read the value before removing dependencies, in case the new value wasn't read - recordedDerivedStateValues[value] = value.currentValue + recordedDerivedStateValues[value] = record.currentValue - val dependencies = value.dependencies + val dependencies = record.dependencies val dependencyToDerivedStates = dependencyToDerivedStates dependencyToDerivedStates.removeScope(value) @@ -542,7 +544,11 @@ class SnapshotStateObserver(private val onChangedExecutor: (callback: () -> Unit val policy = derivedState.policy ?: structuralEqualityPolicy() // Invalidate only if currentValue is different than observed on read - if (!policy.equivalent(derivedState.currentValue, previousValue)) { + if (!policy.equivalent( + derivedState.currentRecord.currentValue, + previousValue + ) + ) { valueToScopes.forEachScopeOf(derivedState) { scope -> invalidated.add(scope) hasValues = true diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawReorderingTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawReorderingTest.kt index 57531e57a87..ebea07382b2 100644 --- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawReorderingTest.kt +++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawReorderingTest.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.zIndex import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress +import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import org.junit.Assert.assertNotNull @@ -1040,20 +1041,28 @@ class DrawReorderingTest { @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) - fun placingInDifferentOrderTriggersRedraw() { + fun changingPlaceOrderInLayout() { var reverseOrder by mutableStateOf(false) + var childRelayoutCount = 0 + val childRelayoutModifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + childRelayoutCount++ + placeable.place(0, 0) + } + } rule.runOnUiThread { activity.setContent { Layout( content = { - FixedSize(30) { + FixedSize(30, childRelayoutModifier) { FixedSize( 10, Modifier.padding(10) .background(Color.White) ) } - FixedSize(30) { + FixedSize(30, childRelayoutModifier) { FixedSize( 30, Modifier.background(Color.Red) @@ -1084,6 +1093,7 @@ class DrawReorderingTest { rule.runOnUiThread { drawLatch = CountDownLatch(1) reverseOrder = true + childRelayoutCount = 0 } rule.validateSquareColors( @@ -1092,6 +1102,74 @@ class DrawReorderingTest { size = 10, drawLatch = drawLatch ) + rule.runOnUiThread { + // changing drawing order doesn't require child's layer block rerun + assertThat(childRelayoutCount).isEqualTo(0) + } + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) + fun changingZIndexInLayout() { + var zIndex by mutableStateOf(1f) + var childRelayoutCount = 0 + val childRelayoutModifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + childRelayoutCount++ + placeable.place(0, 0) + } + } + rule.runOnUiThread { + activity.setContent { + Layout( + content = { + FixedSize(30, childRelayoutModifier) { + FixedSize( + 10, + Modifier.padding(10) + .background(Color.White) + ) + } + FixedSize(30, childRelayoutModifier) { + FixedSize( + 30, + Modifier.background(Color.Red) + ) + } + }, + modifier = Modifier.drawLatchModifier() + ) { measurables, _ -> + val newConstraints = Constraints.fixed(30, 30) + val placeables = measurables.map { m -> + m.measure(newConstraints) + } + layout(newConstraints.maxWidth, newConstraints.maxWidth) { + placeables[0].place(0, 0) + placeables[1].place(0, 0, zIndex) + } + } + } + } + + assertTrue(drawLatch.await(1, TimeUnit.SECONDS)) + + rule.runOnUiThread { + drawLatch = CountDownLatch(1) + zIndex = -1f + childRelayoutCount = 0 + } + + rule.validateSquareColors( + outerColor = Color.Red, + innerColor = Color.White, + size = 10, + drawLatch = drawLatch + ) + rule.runOnUiThread { + // changing zIndex doesn't require child's layer block rerun + assertThat(childRelayoutCount).isEqualTo(0) + } } fun Modifier.drawLatchModifier() = drawBehind { drawLatch.countDown() } diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/GraphicsLayerTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/GraphicsLayerTest.kt index ba80c9152d8..cff91584285 100644 --- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/GraphicsLayerTest.kt +++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/GraphicsLayerTest.kt @@ -121,9 +121,12 @@ class GraphicsLayerTest { rule.setContent { FixedSize( 30, - Modifier.padding(10).graphicsLayer().onGloballyPositioned { - coords = it - } + Modifier + .padding(10) + .graphicsLayer() + .onGloballyPositioned { + coords = it + } ) { /* no-op */ } } @@ -148,9 +151,11 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.graphicsLayer(scaleX = 2f, scaleY = 3f).onGloballyPositioned { - coords = it - } + Modifier + .graphicsLayer(scaleX = 2f, scaleY = 3f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -171,9 +176,11 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.scale(scaleX = 2f, scaleY = 3f).onGloballyPositioned { - coords = it - } + Modifier + .scale(scaleX = 2f, scaleY = 3f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -194,9 +201,11 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.scale(scale = 2f).onGloballyPositioned { - coords = it - } + Modifier + .scale(scale = 2f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -217,9 +226,11 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.graphicsLayer(scaleY = 3f, rotationZ = 90f).onGloballyPositioned { - coords = it - } + Modifier + .graphicsLayer(scaleY = 3f, rotationZ = 90f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -240,9 +251,11 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.rotate(90f).onGloballyPositioned { - coords = it - } + Modifier + .rotate(90f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -263,12 +276,14 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.graphicsLayer( - rotationZ = 90f, - transformOrigin = TransformOrigin(1.0f, 1.0f) - ).onGloballyPositioned { - coords = it - } + Modifier + .graphicsLayer( + rotationZ = 90f, + transformOrigin = TransformOrigin(1.0f, 1.0f) + ) + .onGloballyPositioned { + coords = it + } ) } } @@ -288,12 +303,14 @@ class GraphicsLayerTest { Padding(10) { FixedSize( 10, - Modifier.graphicsLayer( - translationX = 5.0f, - translationY = 8.0f - ).onGloballyPositioned { - coords = it - } + Modifier + .graphicsLayer( + translationX = 5.0f, + translationY = 8.0f + ) + .onGloballyPositioned { + coords = it + } ) } } @@ -314,9 +331,11 @@ class GraphicsLayerTest { FixedSize(10, Modifier.graphicsLayer(clip = true)) { FixedSize( 10, - Modifier.graphicsLayer(scaleX = 2f).onGloballyPositioned { - coords = it - } + Modifier + .graphicsLayer(scaleX = 2f) + .onGloballyPositioned { + coords = it + } ) { } } @@ -339,18 +358,20 @@ class GraphicsLayerTest { rule.setContent { with(LocalDensity.current) { Box( - Modifier.requiredSize(25.toDp()) + Modifier + .requiredSize(25.toDp()) .graphicsLayer( rotationZ = 30f, clip = true ) ) { Box( - Modifier.graphicsLayer( - rotationZ = 90f, - transformOrigin = TransformOrigin(0f, 1f), - clip = true - ) + Modifier + .graphicsLayer( + rotationZ = 90f, + transformOrigin = TransformOrigin(0f, 1f), + clip = true + ) .requiredSize(20.toDp(), 10.toDp()) .align(AbsoluteAlignment.TopLeft) .onGloballyPositioned { @@ -395,7 +416,9 @@ class GraphicsLayerTest { if (Build.VERSION.SDK_INT == 28) return // b/260095151 val testTag = "parent" rule.setContent { - Box(modifier = Modifier.testTag(testTag).wrapContentSize()) { + Box(modifier = Modifier + .testTag(testTag) + .wrapContentSize()) { Box( modifier = Modifier .requiredSize(100.dp) @@ -431,7 +454,10 @@ class GraphicsLayerTest { } val tag = "testTag" rule.setContent { - Box(modifier = Modifier.testTag(tag).requiredSize(100.dp).background(Color.Blue)) { + Box(modifier = Modifier + .testTag(tag) + .requiredSize(100.dp) + .background(Color.Blue)) { Box( modifier = Modifier .matchParentSize() @@ -454,9 +480,11 @@ class GraphicsLayerTest { FixedSize(10, Modifier.graphicsLayer(clip = true)) { FixedSize( 10, - Modifier.padding(20).onGloballyPositioned { - coords = it - } + Modifier + .padding(20) + .onGloballyPositioned { + coords = it + } ) { } } @@ -493,7 +521,8 @@ class GraphicsLayerTest { drawBlock: DrawScope.() -> Unit ) { Box( - Modifier.testTag(tag) + Modifier + .testTag(tag) .size(size) .background(Color.Black) .graphicsLayer { @@ -606,7 +635,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -664,7 +697,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -689,7 +726,8 @@ class GraphicsLayerTest { drawRect( color = Color.Red, topLeft = Offset(-width, -height), - size = Size(width * 2, height * 2)) + size = Size(width * 2, height * 2) + ) } ) } @@ -738,7 +776,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -764,7 +806,8 @@ class GraphicsLayerTest { drawRect( color = Color.Red, topLeft = Offset(-width, -height), - size = Size(width * 2, height * 2)) + size = Size(width * 2, height * 2) + ) } ) } @@ -815,7 +858,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -845,7 +892,8 @@ class GraphicsLayerTest { drawRect( color = Color.Red, topLeft = Offset(-width, -height), - size = Size(width * 2, height * 2)) + size = Size(width * 2, height * 2) + ) } ) } @@ -896,7 +944,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -971,7 +1023,11 @@ class GraphicsLayerTest { val size = (sizePx / density) val squareSize = (squarePx / density) offset = (20f / density).roundToInt() - Box(Modifier.size(size.dp).background(Color.LightGray).testTag(testTag)) { + Box( + Modifier + .size(size.dp) + .background(Color.LightGray) + .testTag(testTag)) { Box( Modifier .layout { measurable, constraints -> @@ -1044,7 +1100,10 @@ class GraphicsLayerTest { rule.setContent { FixedSize( 5, - Modifier.graphicsLayer().testTag("tag").background(color) + Modifier + .graphicsLayer() + .testTag("tag") + .background(color) ) } @@ -1092,14 +1151,18 @@ class GraphicsLayerTest { Layout( content = { Box( - Modifier.fillMaxSize().clickable { - firstClicked = true - } + Modifier + .fillMaxSize() + .clickable { + firstClicked = true + } ) Box( - Modifier.fillMaxSize().clickable { - secondClicked = true - } + Modifier + .fillMaxSize() + .clickable { + secondClicked = true + } ) }, modifier = Modifier.testTag("layout") @@ -1167,7 +1230,8 @@ class GraphicsLayerTest { rule.setContent { Canvas( modifier = - Modifier.testTag(tag) + Modifier + .testTag(tag) .size((dimen / LocalDensity.current.density).dp) .background(Color.Black) .graphicsLayer( @@ -1209,7 +1273,8 @@ class GraphicsLayerTest { rule.setContent { Canvas( modifier = - Modifier.testTag(tag) + Modifier + .testTag(tag) .size((dimen / LocalDensity.current.density).dp) .background(Color.LightGray) .graphicsLayer( @@ -1244,7 +1309,8 @@ class GraphicsLayerTest { rule.setContent { Canvas( modifier = - Modifier.testTag(tag) + Modifier + .testTag(tag) .size((dimen / LocalDensity.current.density).dp) .background(Color.Black) .graphicsLayer( @@ -1360,7 +1426,10 @@ class GraphicsLayerTest { val size = 100 rule.setContent { val sizeDp = with(LocalDensity.current) { size.toDp() } - LazyColumn(Modifier.testTag("lazy").background(Color.Blue)) { + LazyColumn( + Modifier + .testTag("lazy") + .background(Color.Blue)) { items(4) { Box( Modifier @@ -1512,7 +1581,10 @@ class GraphicsLayerTest { } } rule.setContent { - Box(Modifier.graphicsLayer(translationX = translationX).then(layoutModifier)) { + Box( + Modifier + .graphicsLayer(translationX = translationX) + .then(layoutModifier)) { Layout(Modifier.onGloballyPositioned { coordinates = it }) { _, _ -> layout(10, 10) {} } @@ -1550,7 +1622,10 @@ class GraphicsLayerTest { } } rule.setContent { - Box(Modifier.graphicsLayer(lambda).then(layoutModifier)) { + Box( + Modifier + .graphicsLayer(lambda) + .then(layoutModifier)) { Layout(Modifier.onGloballyPositioned { coordinates = it }) { _, _ -> layout(10, 10) {} } @@ -1572,4 +1647,92 @@ class GraphicsLayerTest { assertEquals(0, relayoutCount) } } + + @Test + fun addingLayerForChildDoesntTriggerChildRelayout() { + var relayoutCount = 0 + var modifierRelayoutCount = 0 + var needLayer by mutableStateOf(false) + var layerBlockCalled = false + rule.setContent { + Layout(content = { + Layout( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + modifierRelayoutCount++ + placeable.place(0, 0) + } + } + ) { _, _ -> + layout(10, 10) { + relayoutCount++ + } + } + }) { measurables, constraints -> + val placeable = measurables[0].measure(constraints) + layout(placeable.width, placeable.height) { + if (needLayer) { + placeable.placeWithLayer(0, 0) { + layerBlockCalled = true + } + } else { + placeable.place(0, 0) + } + } + } + } + + rule.runOnIdle { + relayoutCount = 0 + modifierRelayoutCount = 0 + needLayer = true + } + + rule.runOnIdle { + assertEquals(0, relayoutCount) + assertTrue(layerBlockCalled) + assertEquals(0, modifierRelayoutCount) + } + } + + @Test + fun movingChildsLayerDoesntTriggerChildRelayout() { + var relayoutCount = 0 + var modifierRelayoutCount = 0 + var position by mutableStateOf(0) + rule.setContent { + Layout(content = { + Layout( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + modifierRelayoutCount++ + placeable.place(0, 0) + } + } + ) { _, _ -> + layout(10, 10) { + relayoutCount++ + } + } + }) { measurables, constraints -> + val placeable = measurables[0].measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeWithLayer(position, 0) + } + } + } + + rule.runOnIdle { + relayoutCount = 0 + modifierRelayoutCount = 0 + position = 10 + } + + rule.runOnIdle { + assertEquals(0, relayoutCount) + assertEquals(0, modifierRelayoutCount) + } + } } diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LayoutCooperationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LayoutCooperationTest.kt index 154f1fd51b0..e91f316f69b 100644 --- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LayoutCooperationTest.kt +++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LayoutCooperationTest.kt @@ -29,9 +29,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.background import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo +import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntSize import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -52,8 +55,14 @@ class LayoutCooperationTest { val size = 48 var initialOuterSize by mutableStateOf((size / 2).toDp()) rule.setContent { - Box(Modifier.size(initialOuterSize).testTag("outer")) { - Box(Modifier.requiredSize(size.toDp()).background(Color.Yellow)) + Box( + Modifier + .size(initialOuterSize) + .testTag("outer")) { + Box( + Modifier + .requiredSize(size.toDp()) + .background(Color.Yellow)) } } @@ -65,4 +74,45 @@ class LayoutCooperationTest { Color.Yellow } } + + @Test + fun relayoutSkippingModifiersDoesntBreakCooperation() { + with(rule.density) { + val containerSize = 100 + val width = 50 + val widthDp = width.toDp() + val height = 40 + val heightDp = height.toDp() + var offset by mutableStateOf(0) + rule.setContent { + Layout(content = { + Box(Modifier.requiredSize(widthDp, heightDp)) { + Box(Modifier.testTag("child")) + } + }) { measurables, _ -> + val placeable = + measurables.first().measure(Constraints.fixed(containerSize, containerSize)) + layout(containerSize, containerSize) { + placeable.place(offset, offset) + } + } + } + + var expectedTop = ((containerSize - height) / 2).toDp() + var expectedLeft = ((containerSize - width) / 2).toDp() + rule.onNodeWithTag("child") + .assertTopPositionInRootIsEqualTo(expectedTop) + .assertLeftPositionInRootIsEqualTo(expectedLeft) + + rule.runOnIdle { + offset = 10 + } + + expectedTop += offset.toDp() + expectedLeft += offset.toDp() + rule.onNodeWithTag("child") + .assertTopPositionInRootIsEqualTo(expectedTop) + .assertLeftPositionInRootIsEqualTo(expectedLeft) + } + } }
\ No newline at end of file diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt index 378d5b729d7..66c3ac92bdb 100644 --- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt +++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt @@ -1729,6 +1729,7 @@ class LookaheadScopeTest { repeat(3) { id -> subcompose(id) { Box(Modifier.trackMainPassPlacement { + iteration.toString() // state read to make callback called actualPlacementOrder.add(id) }) }.fastMap { it.measure(constraints) }.let { placeables.addAll(it) } @@ -1739,6 +1740,7 @@ class LookaheadScopeTest { val id = index + 3 subcompose(id) { Box(Modifier.trackMainPassPlacement { + iteration.toString() // state read to make callback called actualPlacementOrder.add(id) }) }.fastMap { it.measure(constraints) }.let { allPlaceables.addAll(it) } diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacementLayoutCoordinatesTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacementLayoutCoordinatesTest.kt index f6553819a4f..ca5805c34e7 100644 --- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacementLayoutCoordinatesTest.kt +++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacementLayoutCoordinatesTest.kt @@ -257,7 +257,7 @@ class PlacementLayoutCoordinatesTest { Layout(content, Modifier.alignByBaseline()) { measurables, constraints -> val p = measurables[0].measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -287,7 +287,7 @@ class PlacementLayoutCoordinatesTest { Layout(content, Modifier.alignByBaseline()) { measurables, constraints -> val p = measurables[0].measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -329,7 +329,7 @@ class PlacementLayoutCoordinatesTest { }) { measurables, constraints -> val p = measurables[0].measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -343,7 +343,7 @@ class PlacementLayoutCoordinatesTest { } @Test - fun parentCoordateChangeCausesRelayout() { + fun parentCoordinateChangeCausesRelayout() { val locations = mutableStateListOf<LayoutCoordinates?>() var offset by mutableStateOf(DpOffset(0.dp, 0.dp)) rule.setContent { @@ -354,7 +354,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -386,7 +386,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -422,7 +422,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -459,7 +459,8 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - layoutCalls += if (readCoordinates) coordinates else null + layoutCalls += + if (readCoordinates) coordinates.use() else null p.place(0, 0) } } @@ -507,7 +508,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -568,7 +569,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -604,7 +605,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -634,7 +635,7 @@ class PlacementLayoutCoordinatesTest { .layout { measurable, constraints -> val p = measurable.measure(constraints) layout(p.width, p.height) { - locations += coordinates + locations += coordinates.use() p.place(0, 0) } } @@ -668,4 +669,235 @@ class PlacementLayoutCoordinatesTest { rule.waitForIdle() assertEquals(1, locations.size) } + + @Test + fun readingFromMainLayoutPolicyAfterMultipleMoves() { + var offset by mutableStateOf(0) + var layoutBlockCalls = 0 + rule.setContent { + Layout(content = { + Layout { _, _ -> + layout(10, 10) { + coordinates?.positionInParent() + layoutBlockCalls++ + } + } + }) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(offset, 0) + } + } + } + + rule.runOnIdle { + layoutBlockCalls = 0 + offset = 1 + } + + rule.runOnIdle { + assertEquals(1, layoutBlockCalls) + layoutBlockCalls = 0 + offset = 2 + } + + rule.runOnIdle { + assertEquals(1, layoutBlockCalls) + } + } + + @Test + fun onlyRealPositionReadsTriggerRelayout() { + var offset by mutableStateOf(0) + var coordinatesAction: (LayoutCoordinates) -> Unit by mutableStateOf({}) + var layoutBlockCalls = 0 + rule.setContent { + Layout(content = { + Layout { _, _ -> + layout(10, 10) { + coordinates?.let(coordinatesAction) + layoutBlockCalls++ + } + } + }) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(offset, 0) + } + } + } + + fun assert( + relayoutExpected: Boolean, + description: String, + action: (LayoutCoordinates) -> Unit + ) { + coordinatesAction = action + rule.runOnIdle { + layoutBlockCalls = 0 + offset = if (offset == 0) 10 else 0 + } + rule.runOnIdle { + assertEquals( + "Relayout because of `$description` read was " + + "${if (!relayoutExpected) " not" else ""} expected, but " + + "$layoutBlockCalls calls happened", + if (relayoutExpected) 1 else 0, + layoutBlockCalls + ) + } + } + + assert(relayoutExpected = true, "positionInParent()") { it.positionInParent() } + assert(relayoutExpected = true, "positionInRoot()") { it.positionInRoot() } + assert(relayoutExpected = true, "positionInWindow()") { it.positionInWindow() } + assert(relayoutExpected = true, "boundsInParent()") { it.boundsInParent() } + assert(relayoutExpected = true, "boundsInRoot()") { it.boundsInRoot() } + assert(relayoutExpected = true, "boundsInWindow()") { it.boundsInWindow() } + + assert(relayoutExpected = false, "empty") { } + assert(relayoutExpected = false, "size") { it.size } + assert(relayoutExpected = false, "isAttached") { it.isAttached } + assert(relayoutExpected = false, "providedAlignmentLines") { it.providedAlignmentLines } + } + + @Test + fun onlyRealPositionReadsTriggerRelayout_inModifier() { + var offset by mutableStateOf(0) + var coordinatesAction: (LayoutCoordinates) -> Unit by mutableStateOf({}) + var layoutBlockCalls = 0 + rule.setContent { + Layout(content = { + Box( + Modifier + .layout { measurable, constraints -> + val p = measurable.measure(constraints) + layout(p.width, p.height) { + coordinates?.let(coordinatesAction) + layoutBlockCalls++ + p.place(0, 0) + } + } + ) + }) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(offset, 0) + } + } + } + + fun assert( + relayoutExpected: Boolean, + description: String, + action: (LayoutCoordinates) -> Unit + ) { + coordinatesAction = action + rule.runOnIdle { + layoutBlockCalls = 0 + offset = if (offset == 0) 10 else 0 + } + rule.runOnIdle { + assertEquals( + "Relayout because of `$description` read was " + + "${if (!relayoutExpected) " not" else ""} expected, but " + + "$layoutBlockCalls calls happened", + if (relayoutExpected) 1 else 0, + layoutBlockCalls + ) + } + } + + assert(relayoutExpected = true, "positionInParent()") { it.positionInParent() } + assert(relayoutExpected = true, "positionInRoot()") { it.positionInRoot() } + assert(relayoutExpected = true, "positionInWindow()") { it.positionInWindow() } + assert(relayoutExpected = true, "boundsInParent()") { it.boundsInParent() } + assert(relayoutExpected = true, "boundsInRoot()") { it.boundsInRoot() } + assert(relayoutExpected = true, "boundsInWindow()") { it.boundsInWindow() } + + assert(relayoutExpected = false, "empty") { } + assert(relayoutExpected = false, "size") { it.size } + assert(relayoutExpected = false, "isAttached") { it.isAttached } + assert(relayoutExpected = false, "providedAlignmentLines") { it.providedAlignmentLines } + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun onlyRealPositionReadsTriggerRelayout_inLookahead() { + var offset by mutableStateOf(0) + var coordinatesAction: (LayoutCoordinates) -> Unit by mutableStateOf({}) + var intermediateLayoutBlockCalls = 0 + rule.setContent { + LookaheadScope { + Layout(content = { + Box( + Modifier + .intermediateLayout { measurable, constraints -> + val p = measurable.measure(constraints) + layout(p.width, p.height) { + coordinates?.let(coordinatesAction) + intermediateLayoutBlockCalls++ + p.place(0, 0) + } + } + .layout { measurable, constraints -> + val p = measurable.measure(constraints) + layout(10, 10) { + // if we don't read the coordinates here as well + // the read of coordinates in intermediate layout could be + // skipped as both passes share the same + // coordinatesAccessedDuringPlacement property. + // filed b/284153462 to track this issue + coordinates?.let(coordinatesAction) + p.place(0, 0) + } + } + ) + }) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(offset, 0) + } + } + } + } + + fun assert( + relayoutExpected: Boolean, + description: String, + action: (LayoutCoordinates) -> Unit + ) { + coordinatesAction = action + rule.runOnIdle { + intermediateLayoutBlockCalls = 0 + offset = if (offset == 0) 10 else 0 + } + rule.runOnIdle { + assertEquals( + "Relayout because of `$description` read was " + + "${if (!relayoutExpected) " not" else ""} expected, but " + + "$intermediateLayoutBlockCalls calls happened", + if (relayoutExpected) 1 else 0, + intermediateLayoutBlockCalls + ) + } + } + + assert(relayoutExpected = true, "positionInParent()") { it.positionInParent() } + assert(relayoutExpected = true, "positionInRoot()") { it.positionInRoot() } + assert(relayoutExpected = true, "positionInWindow()") { it.positionInWindow() } + assert(relayoutExpected = true, "boundsInParent()") { it.boundsInParent() } + assert(relayoutExpected = true, "boundsInRoot()") { it.boundsInRoot() } + assert(relayoutExpected = true, "boundsInWindow()") { it.boundsInWindow() } + + assert(relayoutExpected = false, "empty") { } + assert(relayoutExpected = false, "size") { it.size } + assert(relayoutExpected = false, "isAttached") { it.isAttached } + assert(relayoutExpected = false, "providedAlignmentLines") { it.providedAlignmentLines } + } +} + +private fun LayoutCoordinates?.use(): LayoutCoordinates? { + this?.parentCoordinates + return this } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt index fe78b5942a3..a0e6a00351d 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt @@ -870,20 +870,30 @@ internal class AndroidComposeView(context: Context, coroutineContext: CoroutineC } override fun measureAndLayout(sendPointerUpdate: Boolean) { - trace("AndroidOwner:measureAndLayout") { - val resend = if (sendPointerUpdate) resendMotionEventOnLayout else null - val rootNodeResized = measureAndLayoutDelegate.measureAndLayout(resend) - if (rootNodeResized) { - requestLayout() + // only run the logic when we have something pending + if (measureAndLayoutDelegate.hasPendingMeasureOrLayout || + measureAndLayoutDelegate.hasPendingOnPositionedCallbacks + ) { + trace("AndroidOwner:measureAndLayout") { + val resend = if (sendPointerUpdate) resendMotionEventOnLayout else null + val rootNodeResized = measureAndLayoutDelegate.measureAndLayout(resend) + if (rootNodeResized) { + requestLayout() + } + measureAndLayoutDelegate.dispatchOnPositionedCallbacks() } - measureAndLayoutDelegate.dispatchOnPositionedCallbacks() } } override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) { trace("AndroidOwner:measureAndLayout") { measureAndLayoutDelegate.measureAndLayout(layoutNode, constraints) - measureAndLayoutDelegate.dispatchOnPositionedCallbacks() + // only dispatch the callbacks if we don't have other nodes to process as otherwise + // we will have one more measureAndLayout() pass anyway in the same frame. + // it allows us to not traverse the hierarchy twice. + if (!measureAndLayoutDelegate.hasPendingMeasureOrLayout) { + measureAndLayoutDelegate.dispatchOnPositionedCallbacks() + } } } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt index f718f7b6639..a2c822069a1 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt @@ -208,8 +208,12 @@ internal class RenderNodeLayer( val newLeft = position.x val newTop = position.y if (oldLeft != newLeft || oldTop != newTop) { - renderNode.offsetLeftAndRight(newLeft - oldLeft) - renderNode.offsetTopAndBottom(newTop - oldTop) + if (oldLeft != newLeft) { + renderNode.offsetLeftAndRight(newLeft - oldLeft) + } + if (oldTop != newTop) { + renderNode.offsetTopAndBottom(newTop - oldTop) + } triggerRepaint() matrixCache.invalidate() } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt index af49d34430a..e674eed2c7a 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.node.NodeCoordinator import androidx.compose.ui.unit.IntSize /** - * A holder of the measured bounds for the layout (MeasureBox). + * A holder of the measured bounds for the [Layout]. */ @JvmDefaultWithCompatibility interface LayoutCoordinates { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt index 354fe564a8e..a6747907fc1 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LookaheadLayoutCoordinates.kt @@ -89,6 +89,7 @@ internal class LookaheadLayoutCoordinatesImpl(val lookaheadDelegate: LookaheadDe ): Offset { if (sourceCoordinates is LookaheadLayoutCoordinatesImpl) { val source = sourceCoordinates.lookaheadDelegate + source.coordinator.onCoordinatesUsed() val commonAncestor = coordinator.findCommonAncestor(source.coordinator) return commonAncestor.lookaheadDelegate?.let { ancestor -> diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt index 94893a6a42b..b20aa16c347 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt @@ -71,11 +71,11 @@ abstract class Placeable : Measured { set(value) { if (field != value) { field = value - recalculateWidthAndHeight() + onMeasuredSizeChanged() } } - private fun recalculateWidthAndHeight() { + private fun onMeasuredSizeChanged() { width = measuredSize.width.coerceIn( measurementConstraints.minWidth, measurementConstraints.maxWidth @@ -84,6 +84,8 @@ abstract class Placeable : Measured { measurementConstraints.minHeight, measurementConstraints.maxHeight ) + apparentToRealOffset = + IntOffset((width - measuredSize.width) / 2, (height - measuredSize.height) / 2) } /** @@ -110,7 +112,7 @@ abstract class Placeable : Measured { set(value) { if (field != value) { field = value - recalculateWidthAndHeight() + onMeasuredSizeChanged() } } @@ -119,8 +121,8 @@ abstract class Placeable : Measured { * The real layout will be centered on the space assigned by the parent, which computed the * child's position only seeing its apparent size. */ - protected val apparentToRealOffset: IntOffset - get() = IntOffset((width - measuredSize.width) / 2, (height - measuredSize.height) / 2) + protected var apparentToRealOffset: IntOffset = IntOffset.Zero + private set /** * Receiver scope that permits explicit placement of a [Placeable]. @@ -158,6 +160,10 @@ abstract class Placeable : Measured { * When [coordinates] is `null`, there will always be a follow-up placement call in which * [coordinates] is not-`null`. * + * If you read a position from the coordinates during the placement block the block + * will be automatically re-executed when the parent layout changes a position. If you + * don't read it the placement block execution can be skipped as an optimization. + * * @sample androidx.compose.ui.samples.PlacementScopeCoordinatesSample */ open val coordinates: LayoutCoordinates? @@ -340,7 +346,11 @@ abstract class Placeable : Measured { override val coordinates: LayoutCoordinates? get() { - layoutDelegate?.coordinatesAccessedDuringPlacement = true + // if coordinates are not null we will only set this flag when the inner + // coordinate values are read. see NodeCoordinator.onCoordinatesUsed() + if (_coordinates == null) { + layoutDelegate?.onCoordinatesUsed() + } return _coordinates } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeAlignmentLines.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeAlignmentLines.kt index fc84004a882..b682397f370 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeAlignmentLines.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeAlignmentLines.kt @@ -203,7 +203,7 @@ internal sealed class AlignmentLines(val alignmentLinesOwner: AlignmentLinesOwne alignmentLinesOwner.requestMeasure() } if (usedByModifierLayout) { - parent.requestLayout() + alignmentLinesOwner.requestLayout() } parent.alignmentLines.onAlignmentsChanged() } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt index e66dc1cb70a..9e079fe7577 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.node.LayoutNode.LayoutState import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.util.fastForEach /** * This class works as a layout delegate for [LayoutNode]. It delegates all the measure/layout @@ -173,9 +172,30 @@ internal class LayoutNodeLayoutDelegate( val oldValue = field if (oldValue != value) { field = value - if (value) { + if (value && !coordinatesAccessedDuringModifierPlacement) { + // if first out of both flags changes to true increment childrenAccessingCoordinatesDuringPlacement++ - } else { + } else if (!value && !coordinatesAccessedDuringModifierPlacement) { + // if both flags changes to false decrement + childrenAccessingCoordinatesDuringPlacement-- + } + } + } + + /** + * Similar to [coordinatesAccessedDuringPlacement], but tracks the coordinates read happening + * during the modifier layout blocks run. + */ + var coordinatesAccessedDuringModifierPlacement = false + set(value) { + val oldValue = field + if (oldValue != value) { + field = value + if (value && !coordinatesAccessedDuringPlacement) { + // if first out of both flags changes to true increment + childrenAccessingCoordinatesDuringPlacement++ + } else if (!value && !coordinatesAccessedDuringPlacement) { + // if both flags changes to false decrement childrenAccessingCoordinatesDuringPlacement-- } } @@ -216,6 +236,25 @@ internal class LayoutNodeLayoutDelegate( internal var lookaheadPassDelegate: LookaheadPassDelegate? = null private set + fun onCoordinatesUsed() { + val state = layoutNode.layoutState + if (state == LayoutState.LayingOut || state == LayoutState.LookaheadLayingOut) { + if (measurePassDelegate.layingOutChildren) { + coordinatesAccessedDuringPlacement = true + } else { + coordinatesAccessedDuringModifierPlacement = true + } + } + if (state == LayoutState.LookaheadLayingOut) { + // TODO lookahead should have its own flags b/284153462 + if (lookaheadPassDelegate?.layingOutChildren == true) { + coordinatesAccessedDuringPlacement = true + } else { + coordinatesAccessedDuringModifierPlacement = true + } + } + } + /** * [MeasurePassDelegate] manages the measure/layout and alignmentLine related queries for the * actual measure/layout pass. @@ -293,7 +332,11 @@ internal class LayoutNodeLayoutDelegate( return _childDelegates.asMutableList() } + var layingOutChildren = false + private set + override fun layoutChildren() { + layingOutChildren = true alignmentLines.recalculateQueryOwner() if (layoutPending) { @@ -308,6 +351,7 @@ internal class LayoutNodeLayoutDelegate( layoutPending = false val oldLayoutState = layoutState layoutState = LayoutState.LayingOut + coordinatesAccessedDuringPlacement = false with(layoutNode) { val owner = requireOwner() owner.snapshotObserver.observeLayoutSnapshotReads( @@ -341,6 +385,8 @@ internal class LayoutNodeLayoutDelegate( alignmentLines.previousUsedDuringParentLayout = true } if (alignmentLines.dirty && alignmentLines.required) alignmentLines.recalculate() + + layingOutChildren = false } private fun checkChildrenPlaceOrderForUpdates() { @@ -463,7 +509,7 @@ internal class LayoutNodeLayoutDelegate( } private inline fun forEachChildDelegate(block: (MeasurePassDelegate) -> Unit) { - layoutNode.children.fastForEach { + layoutNode.forEachChild { block(it.measurePassDelegate) } } @@ -581,6 +627,10 @@ internal class LayoutNodeLayoutDelegate( layerBlock: (GraphicsLayerScope.() -> Unit)? ) { if (position != lastPosition) { + if (coordinatesAccessedDuringModifierPlacement || + coordinatesAccessedDuringPlacement) { + layoutPending = true + } notifyChildrenUsingCoordinatesWhilePlacing() } // This can actually be called as soon as LookaheadMeasure is done, but devs may expect @@ -603,9 +653,7 @@ internal class LayoutNodeLayoutDelegate( } // Post-lookahead (if any) placement - layoutState = LayoutState.LayingOut placeOuterCoordinator(position, zIndex, layerBlock) - layoutState = LayoutState.Idle } private fun placeOuterCoordinator( @@ -613,26 +661,34 @@ internal class LayoutNodeLayoutDelegate( zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)? ) { + layoutState = LayoutState.LayingOut + lastPosition = position lastZIndex = zIndex lastLayerBlock = layerBlock - placedOnce = true - alignmentLines.usedByModifierLayout = false - coordinatesAccessedDuringPlacement = false + val owner = layoutNode.requireOwner() - owner.snapshotObserver.observeLayoutModifierSnapshotReads( - layoutNode, - affectsLookahead = false - ) { - with(PlacementScope) { - if (layerBlock == null) { - outerCoordinator.place(position, zIndex) - } else { - outerCoordinator.placeWithLayer(position, zIndex, layerBlock) + if (!layoutPending && isPlaced) { + outerCoordinator.placeSelfApparentToRealOffset(position, zIndex, layerBlock) + onNodePlaced() + } else { + alignmentLines.usedByModifierLayout = false + coordinatesAccessedDuringModifierPlacement = false + owner.snapshotObserver.observeLayoutModifierSnapshotReads( + layoutNode, affectsLookahead = false + ) { + with(PlacementScope) { + if (layerBlock == null) { + outerCoordinator.place(position, zIndex) + } else { + outerCoordinator.placeWithLayer(position, zIndex, layerBlock) + } } } } + + layoutState = LayoutState.Idle } /** @@ -730,7 +786,7 @@ internal class LayoutNodeLayoutDelegate( get() = layoutNode.parent?.layoutDelegate?.alignmentLinesOwner override fun forEachChildAlignmentLinesOwner(block: (AlignmentLinesOwner) -> Unit) { - layoutNode.children.fastForEach { + layoutNode.forEachChild { block(it.layoutDelegate.alignmentLinesOwner) } } @@ -756,11 +812,11 @@ internal class LayoutNodeLayoutDelegate( */ fun notifyChildrenUsingCoordinatesWhilePlacing() { if (childrenAccessingCoordinatesDuringPlacement > 0) { - layoutNode.children.fastForEach { child -> + layoutNode.forEachChild { child -> val childLayoutDelegate = child.layoutDelegate - if (childLayoutDelegate.coordinatesAccessedDuringPlacement && - !childLayoutDelegate.layoutPending - ) { + val accessed = childLayoutDelegate.coordinatesAccessedDuringPlacement || + childLayoutDelegate.coordinatesAccessedDuringModifierPlacement + if (accessed && !childLayoutDelegate.layoutPending) { child.requestRelayout() } childLayoutDelegate.measurePassDelegate @@ -939,12 +995,16 @@ internal class LayoutNodeLayoutDelegate( return _childDelegates.asMutableList() } + var layingOutChildren = false + private set + private inline fun forEachChildDelegate(block: (LookaheadPassDelegate) -> Unit) = layoutNode.forEachChild { block(it.layoutDelegate.lookaheadPassDelegate!!) } override fun layoutChildren() { + layingOutChildren = true alignmentLines.recalculateQueryOwner() if (lookaheadLayoutPending) { @@ -961,6 +1021,7 @@ internal class LayoutNodeLayoutDelegate( val oldLayoutState = layoutState layoutState = LayoutState.LookaheadLayingOut val owner = layoutNode.requireOwner() + coordinatesAccessedDuringPlacement = false owner.snapshotObserver.observeLayoutSnapshotReads(layoutNode) { clearPlaceOrder() forEachChildAlignmentLinesOwner { child -> @@ -985,6 +1046,8 @@ internal class LayoutNodeLayoutDelegate( alignmentLines.previousUsedDuringParentLayout = true } if (alignmentLines.dirty && alignmentLines.required) alignmentLines.recalculate() + + layingOutChildren = false } private fun checkChildrenPlaceOrderForUpdates() { @@ -1030,7 +1093,7 @@ internal class LayoutNodeLayoutDelegate( get() = layoutNode.parent?.layoutDelegate?.lookaheadAlignmentLinesOwner override fun forEachChildAlignmentLinesOwner(block: (AlignmentLinesOwner) -> Unit) { - layoutNode.children.fastForEach { + layoutNode.forEachChild { block(it.layoutDelegate.lookaheadAlignmentLinesOwner!!) } } @@ -1056,11 +1119,11 @@ internal class LayoutNodeLayoutDelegate( */ fun notifyChildrenUsingCoordinatesWhilePlacing() { if (childrenAccessingCoordinatesDuringPlacement > 0) { - layoutNode.children.fastForEach { child -> + layoutNode.forEachChild { child -> val childLayoutDelegate = child.layoutDelegate - if (childLayoutDelegate.coordinatesAccessedDuringPlacement && - !childLayoutDelegate.layoutPending - ) { + val accessed = childLayoutDelegate.coordinatesAccessedDuringPlacement || + childLayoutDelegate.coordinatesAccessedDuringModifierPlacement + if (accessed && !childLayoutDelegate.layoutPending) { child.requestLookaheadRelayout() } childLayoutDelegate.lookaheadPassDelegate @@ -1160,14 +1223,23 @@ internal class LayoutNodeLayoutDelegate( layoutState = LayoutState.LookaheadLayingOut placedOnce = true if (position != lastPosition) { + if (coordinatesAccessedDuringModifierPlacement || + coordinatesAccessedDuringPlacement) { + lookaheadLayoutPending = true + } notifyChildrenUsingCoordinatesWhilePlacing() } - alignmentLines.usedByModifierLayout = false val owner = layoutNode.requireOwner() - coordinatesAccessedDuringPlacement = false - owner.snapshotObserver.observeLayoutModifierSnapshotReads(layoutNode) { - with(PlacementScope) { - outerCoordinator.lookaheadDelegate!!.place(position) + + if (!lookaheadLayoutPending && isPlaced) { + onNodePlaced() + } else { + coordinatesAccessedDuringModifierPlacement = false + alignmentLines.usedByModifierLayout = false + owner.snapshotObserver.observeLayoutModifierSnapshotReads(layoutNode) { + with(PlacementScope) { + outerCoordinator.lookaheadDelegate!!.place(position) + } } } lastPosition = position @@ -1294,8 +1366,8 @@ internal class LayoutNodeLayoutDelegate( } if (parent != null) { if (!relayoutWithoutParentInProgress && - parent.layoutState == LayoutState.LayingOut || - parent.layoutState == LayoutState.LookaheadLayingOut + (parent.layoutState == LayoutState.LayingOut || + parent.layoutState == LayoutState.LookaheadLayingOut) ) { // the parent is currently placing its children check(placeOrder == NotPlacedPlaceOrder) { diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt index d297aeff46b..e743af77f9a 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt @@ -48,6 +48,11 @@ internal class MeasureAndLayoutDelegate(private val root: LayoutNode) { val hasPendingMeasureOrLayout get() = relayoutNodes.isNotEmpty() /** + * Whether any on positioned callbacks need to be dispatched + */ + val hasPendingOnPositionedCallbacks get() = onPositionedDispatcher.isNotEmpty() + + /** * Flag to indicate that we're currently measuring. */ private var duringMeasureLayout = false @@ -365,7 +370,7 @@ internal class MeasureAndLayoutDelegate(private val root: LayoutNode) { private fun recurseRemeasure(layoutNode: LayoutNode) { remeasureOnly(layoutNode) - layoutNode._children.forEach { child -> + layoutNode.forEachChild { child -> if (child.measureAffectsParent) { recurseRemeasure(child) } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt index 2a0b6e9f93b..f80a25234af 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt @@ -247,15 +247,21 @@ internal abstract class NodeCoordinator( return null } + internal fun onCoordinatesUsed() { + layoutNode.layoutDelegate.onCoordinatesUsed() + } + final override val parentLayoutCoordinates: LayoutCoordinates? get() { check(isAttached) { ExpectAttachedLayoutCoordinates } + onCoordinatesUsed() return layoutNode.outerCoordinator.wrappedBy } final override val parentCoordinates: LayoutCoordinates? get() { check(isAttached) { ExpectAttachedLayoutCoordinates } + onCoordinatesUsed() return wrappedBy } @@ -301,6 +307,14 @@ internal abstract class NodeCoordinator( zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)? ) { + placeSelf(position, zIndex, layerBlock) + } + + private fun placeSelf( + position: IntOffset, + zIndex: Float, + layerBlock: (GraphicsLayerScope.() -> Unit)? + ) { updateLayerBlock(layerBlock) if (this.position != position) { this.position = position @@ -318,6 +332,14 @@ internal abstract class NodeCoordinator( this.zIndex = zIndex } + fun placeSelfApparentToRealOffset( + position: IntOffset, + zIndex: Float, + layerBlock: (GraphicsLayerScope.() -> Unit)? + ) { + placeSelf(position + apparentToRealOffset, zIndex, layerBlock) + } + /** * Draws the content of the LayoutNode */ @@ -374,9 +396,9 @@ internal abstract class NodeCoordinator( layerBlock: (GraphicsLayerScope.() -> Unit)?, forceUpdateLayerParameters: Boolean = false ) { - val updateParameters = this.layerBlock !== layerBlock || layerDensity != layoutNode - .density || layerLayoutDirection != layoutNode.layoutDirection || - forceUpdateLayerParameters + val layoutNode = layoutNode + val updateParameters = forceUpdateLayerParameters || this.layerBlock !== layerBlock || + layerDensity != layoutNode.density || layerLayoutDirection != layoutNode.layoutDirection this.layerBlock = layerBlock this.layerDensity = layoutNode.density this.layerLayoutDirection = layoutNode.layoutDirection @@ -737,6 +759,7 @@ internal abstract class NodeCoordinator( } val nodeCoordinator = sourceCoordinates.toCoordinator() + nodeCoordinator.onCoordinatesUsed() val commonAncestor = findCommonAncestor(nodeCoordinator) var position = relativeToSource @@ -751,6 +774,7 @@ internal abstract class NodeCoordinator( override fun transformFrom(sourceCoordinates: LayoutCoordinates, matrix: Matrix) { val coordinator = sourceCoordinates.toCoordinator() + coordinator.onCoordinatesUsed() val commonAncestor = findCommonAncestor(coordinator) matrix.reset() @@ -795,6 +819,7 @@ internal abstract class NodeCoordinator( "LayoutCoordinates $sourceCoordinates is not attached!" } val srcCoordinator = sourceCoordinates.toCoordinator() + srcCoordinator.onCoordinatesUsed() val commonAncestor = findCommonAncestor(srcCoordinator) val bounds = rectCache @@ -842,6 +867,7 @@ internal abstract class NodeCoordinator( override fun localToRoot(relativeToLocal: Offset): Offset { check(isAttached) { ExpectAttachedLayoutCoordinates } + onCoordinatesUsed() var coordinator: NodeCoordinator? = this var position = relativeToLocal while (coordinator != null) { @@ -1178,7 +1204,8 @@ internal abstract class NodeCoordinator( val layoutNode = coordinator.layoutNode val layoutDelegate = layoutNode.layoutDelegate if (layoutDelegate.childrenAccessingCoordinatesDuringPlacement > 0) { - if (layoutDelegate.coordinatesAccessedDuringPlacement) { + if (layoutDelegate.coordinatesAccessedDuringModifierPlacement || + layoutDelegate.coordinatesAccessedDuringPlacement) { layoutNode.requestRelayout() } layoutDelegate.measurePassDelegate diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OnPositionedDispatcher.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OnPositionedDispatcher.kt index 07b7f50c268..3e9e6199ded 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OnPositionedDispatcher.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OnPositionedDispatcher.kt @@ -25,6 +25,8 @@ import androidx.compose.runtime.collection.mutableVectorOf internal class OnPositionedDispatcher { private val layoutNodes = mutableVectorOf<LayoutNode>() + fun isNotEmpty() = layoutNodes.isNotEmpty() + fun onNodePositioned(node: LayoutNode) { layoutNodes += node node.needsOnPositionedDispatch = true diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt index df004df15be..74a2ab71a60 100644 --- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt +++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt @@ -521,10 +521,10 @@ class LayoutNodeTest { @Test fun testLocalPositionOfWithSiblings() { - val node0 = LayoutNode() + val node0 = ZeroSizedLayoutNode() node0.attach(MockOwner()) - val node1 = LayoutNode() - val node2 = LayoutNode() + val node1 = ZeroSizedLayoutNode() + val node2 = ZeroSizedLayoutNode() node0.insertAt(0, node1) node0.insertAt(1, node2) node1.place(10, 20) diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListBeyondBoundsTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListBeyondBoundsTest.kt index 149cf479769..45447046951 100644 --- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListBeyondBoundsTest.kt +++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListBeyondBoundsTest.kt @@ -39,9 +39,12 @@ import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.B import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Below import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule @@ -107,7 +110,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -127,7 +130,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -147,7 +150,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } } @@ -181,8 +184,7 @@ class LazyListBeyondBoundsTest(param: Param) { } // Act. - rule.waitForIdle() - val hasMoreContent = rule.runOnUiThread { + val hasMoreContent = rule.runOnIdle { beyondBoundsLayoutRef.layout(beyondBoundsLayoutDirection) { hasMoreContent } @@ -202,28 +204,27 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier - .size(10.toDp()) - .onPlaced { placedItems += 5 } - .modifierLocalConsumer { - beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current - } + .size(10.toDp()) + .trackPlaced(5) + .modifierLocalConsumer { + beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current + } ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { @@ -236,7 +237,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -259,14 +259,14 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -276,17 +276,15 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (--extraItemCount > 0) { - placedItems.clear() // Return null to continue the search. null } else { @@ -298,7 +296,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to stop the search. true } @@ -320,7 +317,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { @@ -330,26 +327,22 @@ class LazyListBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. rule.runOnUiThread { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { if (hasMoreContent) { - placedItems.clear() // Just return null so that we keep adding more items till we reach the end. null } else { @@ -361,7 +354,6 @@ class LazyListBeyondBoundsTest(param: Param) { assertThat(placedItems).containsExactly(5, 6, 7, 8, 9, 10) assertThat(visibleItems).containsExactly(5, 6, 7) } - placedItems.clear() // Return true to end the search. true } @@ -383,14 +375,14 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index } + .trackPlaced(index) ) } item { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += 5 } + .trackPlaced(5) .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } @@ -400,14 +392,13 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { placedItems += index + 6 } + .trackPlaced(index + 6) ) } } rule.runOnIdle { assertThat(placedItems).containsExactly(5, 6, 7) assertThat(visibleItems).containsExactly(5, 6, 7) - placedItems.clear() } // Act. @@ -416,7 +407,6 @@ class LazyListBeyondBoundsTest(param: Param) { beyondBoundsLayoutCount++ when (beyondBoundsLayoutDirection) { Left, Right, Above, Below -> { - assertThat(placedItems).containsExactlyElementsIn(visibleItems) assertThat(placedItems).containsExactly(5, 6, 7) assertThat(visibleItems).containsExactly(5, 6, 7) } @@ -430,7 +420,6 @@ class LazyListBeyondBoundsTest(param: Param) { } } } - placedItems.clear() // Just return true so that we stop as soon as we run this once. // This should result in one extra item being added. true @@ -462,9 +451,7 @@ class LazyListBeyondBoundsTest(param: Param) { Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index - } + .trackPlaced(index) ) } item { @@ -474,20 +461,17 @@ class LazyListBeyondBoundsTest(param: Param) { .modifierLocalConsumer { beyondBoundsLayout = ModifierLocalBeyondBoundsLayout.current } - .onPlaced { placedItems += 5 } + .trackPlaced(5) ) } items(5) { index -> Box( Modifier .size(10.toDp()) - .onPlaced { - placedItems += index + 6 - } + .trackPlaced(index + 6) ) } } - rule.runOnIdle { placedItems.clear() } // Act. var count = 0 @@ -495,7 +479,6 @@ class LazyListBeyondBoundsTest(param: Param) { beyondBoundsLayout!!.layout(beyondBoundsLayoutDirection) { // Assert that we don't keep iterating when there is no ending condition. assertThat(count++).isLessThan(lazyListState.layoutInfo.totalItemsCount) - placedItems.clear() // Always return null to continue the search. null } @@ -515,7 +498,9 @@ class LazyListBeyondBoundsTest(param: Param) { Column { BasicText( text = "Outer button", - Modifier.focusRequester(buttonFocusRequester).focusable()) + Modifier + .focusRequester(buttonFocusRequester) + .focusable()) TvLazyColumn { items(3) { @@ -617,4 +602,37 @@ class LazyListBeyondBoundsTest(param: Param) { private fun unsupportedDirection(): Nothing = error( "Lazy list does not support beyond bounds layout for the specified direction" ) + + private fun Modifier.trackPlaced(index: Int): Modifier = + this then TrackPlacedElement(placedItems, index) +} + +internal data class TrackPlacedElement( + var placedItems: MutableSet<Int>, + var index: Int +) : ModifierNodeElement<TrackPlacedNode>() { + override fun create() = TrackPlacedNode(placedItems, index) + + override fun update(node: TrackPlacedNode) { + node.placedItems = placedItems + node.index = index + } + + override fun InspectorInfo.inspectableProperties() { + name = "trackPlaced" + properties["index"] = index + } +} + +internal class TrackPlacedNode( + var placedItems: MutableSet<Int>, + var index: Int +) : LayoutAwareModifierNode, Modifier.Node() { + override fun onPlaced(coordinates: LayoutCoordinates) { + placedItems += index + } + + override fun onDetach() { + placedItems -= index + } } |