Make LazyPagingItems' itemCount and item getter observable
This change removes hack recomposerPlaceholder we had to trigger the recompositions. It allows LazyPagingItems usage outside of official item/itemIndexed functions, for example with experimental LazyVerticalGrid.
Also this should help with recompositions granularity. For example if we receive an event from Pager that the item 2 has been changed we can only recompose this item.
Relnote: LazyPagingItems' itemCount and item getter are now observable which allows it to be used with LazyVerticalGrid as well
Test: new tests in LazyPagingItemsTest
Bug: 171872064
Bug: 168285687
Change-Id: Ie24468ec51660c144acab71e8e520ac09d00b023
diff --git a/paging/paging-compose/api/current.txt b/paging/paging-compose/api/current.txt
index e9c222a..2533412 100644
--- a/paging/paging-compose/api/current.txt
+++ b/paging/paging-compose/api/current.txt
@@ -2,7 +2,7 @@
package androidx.paging.compose {
public final class LazyPagingItems<T> {
- method public operator T? get(int index);
+ method @androidx.compose.runtime.Composable public androidx.compose.runtime.State<T> getAsState(int index);
method public int getItemCount();
method public androidx.paging.CombinedLoadStates getLoadState();
method public T? peek(int index);
diff --git a/paging/paging-compose/api/public_plus_experimental_current.txt b/paging/paging-compose/api/public_plus_experimental_current.txt
index e9c222a..2533412 100644
--- a/paging/paging-compose/api/public_plus_experimental_current.txt
+++ b/paging/paging-compose/api/public_plus_experimental_current.txt
@@ -2,7 +2,7 @@
package androidx.paging.compose {
public final class LazyPagingItems<T> {
- method public operator T? get(int index);
+ method @androidx.compose.runtime.Composable public androidx.compose.runtime.State<T> getAsState(int index);
method public int getItemCount();
method public androidx.paging.CombinedLoadStates getLoadState();
method public T? peek(int index);
diff --git a/paging/paging-compose/api/restricted_current.txt b/paging/paging-compose/api/restricted_current.txt
index e9c222a..2533412 100644
--- a/paging/paging-compose/api/restricted_current.txt
+++ b/paging/paging-compose/api/restricted_current.txt
@@ -2,7 +2,7 @@
package androidx.paging.compose {
public final class LazyPagingItems<T> {
- method public operator T? get(int index);
+ method @androidx.compose.runtime.Composable public androidx.compose.runtime.State<T> getAsState(int index);
method public int getItemCount();
method public androidx.paging.CombinedLoadStates getLoadState();
method public T? peek(int index);
diff --git a/paging/paging-compose/build.gradle b/paging/paging-compose/build.gradle
index 183eca4..3c21e24 100644
--- a/paging/paging-compose/build.gradle
+++ b/paging/paging-compose/build.gradle
@@ -37,6 +37,7 @@
api(projectOrArtifact(":paging:paging-common"))
androidTestImplementation(projectOrArtifact(":compose:ui:ui-test-junit4"))
+ androidTestImplementation(projectOrArtifact(":compose:test-utils"))
androidTestImplementation(projectOrArtifact(":internal-testutils-paging"))
androidTestImplementation(ANDROIDX_TEST_RUNNER)
androidTestImplementation(JUNIT)
diff --git a/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt b/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
index 53dcc09..186fb30 100644
--- a/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
+++ b/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
@@ -21,9 +21,11 @@
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
@@ -63,8 +65,9 @@
@Test
fun lazyPagingColumnShowsItems() {
+ val pager = createPager()
rule.setContent {
- val lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn(Modifier.height(200.dp)) {
items(lazyPagingItems) {
Spacer(Modifier.height(101.dp).fillParentMaxWidth().testTag("$it"))
@@ -89,8 +92,9 @@
@Test
fun lazyPagingColumnShowsIndexedItems() {
+ val pager = createPager()
rule.setContent {
- val lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn(Modifier.height(200.dp)) {
itemsIndexed(lazyPagingItems) { index, item ->
Spacer(
@@ -118,8 +122,9 @@
@Test
fun lazyPagingRowShowsItems() {
+ val pager = createPager()
rule.setContent {
- val lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyRow(Modifier.width(200.dp)) {
items(lazyPagingItems) {
Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag("$it"))
@@ -144,8 +149,9 @@
@Test
fun lazyPagingRowShowsIndexedItems() {
+ val pager = createPager()
rule.setContent {
- val lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyRow(Modifier.width(200.dp)) {
itemsIndexed(lazyPagingItems) { index, item ->
Spacer(
@@ -174,14 +180,19 @@
@Test
fun snapshot() {
lateinit var lazyPagingItems: LazyPagingItems<Int>
+ val pager = createPager()
+ var composedCount = 0
rule.setContent {
- lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+
+ for (i in 0 until lazyPagingItems.itemCount) {
+ lazyPagingItems.getAsState(i).value
+ }
+ composedCount = lazyPagingItems.itemCount
}
- // Trigger page fetch until all items are loaded
- for (i in items.indices) {
- lazyPagingItems.get(i)
- rule.waitForIdle()
+ rule.waitUntil {
+ composedCount == items.size
}
assertThat(lazyPagingItems.snapshot()).isEqualTo(items)
@@ -190,22 +201,33 @@
@Test
fun peek() {
lateinit var lazyPagingItems: LazyPagingItems<Int>
+ var composedCount = 0
+ val pager = createPager()
rule.setContent {
- lazyPagingItems = createPager().flow.collectAsLazyPagingItems()
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+
+ // Trigger page fetch until all items 0-6 are loaded
+ for (i in 0 until minOf(lazyPagingItems.itemCount, 5)) {
+ lazyPagingItems.getAsState(i).value
+ }
+ composedCount = lazyPagingItems.itemCount
}
- // Trigger page fetch until all items 0-6 are loaded
- for (i in 0..4) {
- lazyPagingItems.get(i)
- rule.waitForIdle()
+ rule.waitUntil {
+ composedCount == 6
}
- assertThat(lazyPagingItems.itemCount).isEqualTo(6)
- for (i in 0..4) {
- assertThat(lazyPagingItems.peek(i)).isEqualTo(items[i])
+ rule.runOnIdle {
+ assertThat(lazyPagingItems.itemCount).isEqualTo(6)
+ for (i in 0..4) {
+ assertThat(lazyPagingItems.peek(i)).isEqualTo(items[i])
+ }
}
- // Verify peek does not trigger page fetch.
- assertThat(lazyPagingItems.itemCount).isEqualTo(6)
+
+ rule.runOnIdle {
+ // Verify peek does not trigger page fetch.
+ assertThat(lazyPagingItems.itemCount).isEqualTo(6)
+ }
}
@Test
@@ -262,4 +284,217 @@
assertThat(lazyPagingItems.snapshot()).isNotEmpty()
assertThat(factoryCallCount).isEqualTo(2)
}
-}
\ No newline at end of file
+
+ @Test
+ fun itemCountIsObservable() {
+ val items = mutableListOf(0, 1)
+ val pager = createPager {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ var composedCount = 0
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ composedCount = lazyPagingItems.itemCount
+ }
+
+ rule.waitUntil {
+ composedCount == 2
+ }
+
+ rule.runOnIdle {
+ items += 2
+ lazyPagingItems.refresh()
+ }
+
+ rule.waitUntil {
+ composedCount == 3
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.add(0)
+ lazyPagingItems.refresh()
+ }
+
+ rule.waitUntil {
+ composedCount == 1
+ }
+ }
+
+ @Test
+ fun worksWhenUsedWithoutExtension() {
+ val items = mutableListOf(10, 20)
+ val pager = createPager {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn(Modifier.height(300.dp)) {
+ items(lazyPagingItems.itemCount) {
+ val item by lazyPagingItems.getAsState(it)
+ Spacer(Modifier.height(101.dp).fillParentMaxWidth().testTag("$item"))
+ }
+ }
+ }
+
+ rule.onNodeWithTag("10")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("20")
+ .assertIsDisplayed()
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(30, 20, 40))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithTag("30")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("20")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("40")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("10")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun updatingItem() {
+ val items = mutableListOf(1, 2, 3)
+ val pager = createPager(
+ PagingConfig(
+ pageSize = 3,
+ enablePlaceholders = false,
+ maxSize = 200,
+ initialLoadSize = 3,
+ prefetchDistance = 3,
+ )
+ ) {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ val itemSize = with(rule.density) { 100.dp.roundToPx().toDp() }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn(Modifier.height(itemSize * 3)) {
+ items(lazyPagingItems) {
+ Spacer(Modifier.height(itemSize).fillParentMaxWidth().testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(1, 4, 3))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithTag("1")
+ .assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("4")
+ .assertTopPositionInRootIsEqualTo(itemSize)
+
+ rule.onNodeWithTag("3")
+ .assertTopPositionInRootIsEqualTo(itemSize * 2)
+
+ rule.onNodeWithTag("2")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun addingNewItem() {
+ val items = mutableListOf(1, 2)
+ val pager = createPager(
+ PagingConfig(
+ pageSize = 3,
+ enablePlaceholders = false,
+ maxSize = 200,
+ initialLoadSize = 3,
+ prefetchDistance = 3,
+ )
+ ) {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ val itemSize = with(rule.density) { 100.dp.roundToPx().toDp() }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn(Modifier.height(itemSize * 3)) {
+ items(lazyPagingItems) {
+ Spacer(Modifier.height(itemSize).fillParentMaxWidth().testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(1, 2, 3))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithTag("1")
+ .assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("2")
+ .assertTopPositionInRootIsEqualTo(itemSize)
+
+ rule.onNodeWithTag("3")
+ .assertTopPositionInRootIsEqualTo(itemSize * 2)
+ }
+
+ @Test
+ fun removingItem() {
+ val items = mutableListOf(1, 2, 3)
+ val pager = createPager(
+ PagingConfig(
+ pageSize = 3,
+ enablePlaceholders = false,
+ maxSize = 200,
+ initialLoadSize = 3,
+ prefetchDistance = 3,
+ )
+ ) {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ val itemSize = with(rule.density) { 100.dp.roundToPx().toDp() }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn(Modifier.height(itemSize * 3)) {
+ items(lazyPagingItems) {
+ Spacer(Modifier.height(itemSize).fillParentMaxWidth().testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(2, 3))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithTag("2")
+ .assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("3")
+ .assertTopPositionInRootIsEqualTo(itemSize)
+
+ rule.onNodeWithTag("1")
+ .assertDoesNotExist()
+ }
+}
diff --git a/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt b/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
index e0cddcb..0df5445 100644
--- a/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
+++ b/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
@@ -20,8 +20,9 @@
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,7 +40,6 @@
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
/**
* The class responsible for accessing the data from a [Flow] of [PagingData].
@@ -54,30 +54,43 @@
public class LazyPagingItems<T : Any> internal constructor(
private val flow: Flow<PagingData<T>>
) {
+ private val mainDispatcher = Dispatchers.Main
+
/**
* The number of items which can be accessed.
*/
- public val itemCount: Int
- get() = pagingDataDiffer.size
+ var itemCount: Int by mutableStateOf(0)
+ private set
- private val mainDispatcher = Dispatchers.Main
-
- internal val recomposerPlaceholder: MutableState<Int> = mutableStateOf(0)
+ /**
+ * Set of value holders associated with the currently (composed) indexes. Once we got a new
+ * value from the repository we can just update the value in the state.
+ */
+ private val activeHolders = HashSet<ActiveItemValueHolder<T>>()
@SuppressLint("RestrictedApi")
- private val differCallback = object : DifferCallback {
+ private val differCallback: DifferCallback = object : DifferCallback {
override fun onChanged(position: Int, count: Int) {
- // TODO: b/168285687 explore how to recompose only when the currently visible item
- // has been changed
- recomposerPlaceholder.value++
+ if (count > 0) {
+ updateValueHolders(position, count)
+ }
}
override fun onInserted(position: Int, count: Int) {
- recomposerPlaceholder.value++
+ if (count > 0) {
+ // we have to update all the items starting from this position as the insertion
+ // changes the positions for all the next items
+ updateValueHolders(position, pagingDataDiffer.size - position)
+ }
}
override fun onRemoved(position: Int, count: Int) {
- recomposerPlaceholder.value++
+ if (count > 0) {
+ // we have to update all the items starting from this position as the removal
+ // changes the positions for all the next items. plus we also want to set null
+ // for the items which are now out of the valid bounds.
+ updateValueHolders(position, pagingDataDiffer.size + count - position)
+ }
}
}
@@ -90,26 +103,57 @@
newList: NullPaddedList<T>,
newCombinedLoadStates: CombinedLoadStates,
lastAccessedIndex: Int,
- onListPresentable: () -> Unit,
+ onListPresentable: () -> Unit
): Int? {
onListPresentable()
- // TODO: This logic may be changed after the implementation of an async model which
- // composes the offscreen elements
- recomposerPlaceholder.value++
+ val oldSize = itemCount
+ updateValueHolders(0, maxOf(newList.size, oldSize))
return null
}
}
/**
- * Returns the item specified at [index] and notifies Paging of the item accessed in
- * order to trigger any loads necessary to fulfill [PagingConfig.prefetchDistance].
+ * Returns the state containing the item specified at [index] and notifies Paging of the item
+ * accessed in order to trigger any loads necessary to fulfill [PagingConfig.prefetchDistance].
*
* @param index the index of the item which should be returned.
- * @return the item specified at [index] or null if the [index] is not between correct
- * bounds or the item is a placeholder.
+ * @return the state containing the item specified at [index] or null if the item is a
+ * placeholder or [index] is not within the correct bounds.
*/
- public operator fun get(index: Int): T? {
- return pagingDataDiffer[index]
+ @Composable
+ fun getAsState(index: Int): State<T?> {
+ require(index >= 0) { "Index can't be negative. $index was passed." }
+ val holder = remember(index) {
+ val initial = if (index < pagingDataDiffer.size) {
+ pagingDataDiffer[index]
+ } else {
+ null
+ }
+ ActiveItemValueHolder(index, initial)
+ }
+ DisposableEffect(index) {
+ activeHolders.add(holder)
+ onDispose {
+ activeHolders.remove(holder)
+ }
+ }
+ return holder.state
+ }
+
+ private fun updateValueHolders(position: Int, count: Int) {
+ itemCount = pagingDataDiffer.size
+ if (count > 0) {
+ activeHolders.forEach {
+ if (it.index in position until position + count) {
+ val newValue = if (it.index < pagingDataDiffer.size) {
+ pagingDataDiffer[it.index]
+ } else {
+ null
+ }
+ it.state.value = newValue
+ }
+ }
+ }
}
/**
@@ -172,7 +216,7 @@
refresh = InitialLoadStates.refresh,
prepend = InitialLoadStates.prepend,
append = InitialLoadStates.append,
- source = InitialLoadStates,
+ source = InitialLoadStates
)
)
private set
@@ -188,6 +232,10 @@
pagingDataDiffer.collectFrom(it)
}
}
+
+ private class ActiveItemValueHolder<T : Any>(val index: Int, initialValue: T?) {
+ val state = mutableStateOf(initialValue)
+ }
}
private val IncompleteLoadState = LoadState.NotLoading(false)
@@ -209,8 +257,10 @@
val lazyPagingItems = remember(this) { LazyPagingItems(this) }
LaunchedEffect(lazyPagingItems) {
- launch { lazyPagingItems.collectPagingData() }
- launch { lazyPagingItems.collectLoadState() }
+ lazyPagingItems.collectPagingData()
+ }
+ LaunchedEffect(lazyPagingItems) {
+ lazyPagingItems.collectLoadState()
}
return lazyPagingItems
@@ -218,7 +268,7 @@
/**
* Adds the [LazyPagingItems] and their content to the scope. The range from 0 (inclusive) to
- * [LazyPagingItems.itemCount] (inclusive) always represents the full range of presentable items,
+ * [LazyPagingItems.itemCount] (exclusive) always represents the full range of presentable items,
* because every event from [PagingDataDiffer] will trigger a recomposition.
*
* @sample androidx.paging.compose.samples.ItemsDemo
@@ -232,20 +282,14 @@
lazyPagingItems: LazyPagingItems<T>,
itemContent: @Composable LazyItemScope.(value: T?) -> Unit
) {
- // this state recomposes every time the LazyPagingItems receives an update and changes the
- // value of recomposerPlaceholder
- @Suppress("UNUSED_VARIABLE")
- val recomposerPlaceholder = lazyPagingItems.recomposerPlaceholder.value
-
items(lazyPagingItems.itemCount) { index ->
- val item = lazyPagingItems[index]
- itemContent(item)
+ itemContent(lazyPagingItems.getAsState(index).value)
}
}
/**
* Adds the [LazyPagingItems] and their content to the scope where the content of an item is
- * aware of its local index. The range from 0 (inclusive) to [LazyPagingItems.itemCount] (inclusive)
+ * aware of its local index. The range from 0 (inclusive) to [LazyPagingItems.itemCount] (exclusive)
* always represents the full range of presentable items, because every event from
* [PagingDataDiffer] will trigger a recomposition.
*
@@ -260,13 +304,7 @@
lazyPagingItems: LazyPagingItems<T>,
itemContent: @Composable LazyItemScope.(index: Int, value: T?) -> Unit
) {
- // this state recomposes every time the LazyPagingItems receives an update and changes the
- // value of recomposerPlaceholder
- @Suppress("UNUSED_VARIABLE")
- val recomposerPlaceholder = lazyPagingItems.recomposerPlaceholder.value
-
items(lazyPagingItems.itemCount) { index ->
- val item = lazyPagingItems[index]
- itemContent(index, item)
+ itemContent(index, lazyPagingItems.getAsState(index).value)
}
}