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