From 63ddcf041f16cb510188bb2f64cb8cb9848de3ce Mon Sep 17 00:00:00 2001
From: Jingwei <jingwei@squareup.com>
Date: Fri, 8 Dec 2023 12:19:31 -0800
Subject: [PATCH] Return SizeOnlyPlaceholder when the placeholder queue is
 empty (#1733)

Co-authored-by: Jesse Wilson <jwilson@squareup.com>
---
 .../redwood/lazylayout/compose/LazyList.kt    | 28 +++----------
 .../redwood/lazylayout/view/ViewLazyList.kt   |  8 ++++
 .../widget/LazyListUpdateProcessor.kt         | 39 +++++++++++++++++--
 .../android/views/EmojiSearchActivity.kt      |  4 ++
 4 files changed, 53 insertions(+), 26 deletions(-)

diff --git a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt
index ec36e94eda..2437e69aab 100644
--- a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt
+++ b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt
@@ -20,8 +20,6 @@ package app.cash.redwood.lazylayout.compose
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import app.cash.redwood.Modifier
 import app.cash.redwood.layout.api.Constraint
@@ -45,23 +43,14 @@ internal fun LazyList(
   val itemCount = itemProvider.itemCount
   val itemsBefore = (state.firstIndex - state.preloadBeforeItemCount).coerceAtLeast(0)
   val itemsAfter = (itemCount - (state.lastIndex + state.preloadAfterItemCount).coerceAtMost(itemCount)).coerceAtLeast(0)
-  // TODO(jwilson): drop this down to 20 once this is fixed:
-  //     https://github.com/cashapp/redwood/issues/1551
-  var placeholderPoolSize by remember { mutableStateOf(30) }
+  val placeholderPoolSize = 30
   LazyList(
-    isVertical,
-    itemsBefore = itemsBefore,
-    itemsAfter = itemsAfter,
+    isVertical = isVertical,
     onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
       state.onUserScroll(localFirstVisibleItemIndex, localLastVisibleItemIndex)
-
-      val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
-      val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
-      // We only ever want to increase the pool size.
-      if (placeholderPoolSize < proposedPlaceholderPoolSize) {
-        placeholderPoolSize = proposedPlaceholderPoolSize
-      }
     },
+    itemsBefore = itemsBefore,
+    itemsAfter = itemsAfter,
     width = width,
     height = height,
     margin = margin,
@@ -98,20 +87,13 @@ internal fun RefreshableLazyList(
   val itemCount = itemProvider.itemCount
   val itemsBefore = (state.firstIndex - state.preloadBeforeItemCount).coerceAtLeast(0)
   val itemsAfter = (itemCount - (state.lastIndex + state.preloadAfterItemCount).coerceAtMost(itemCount)).coerceAtLeast(0)
-  var placeholderPoolSize by remember { mutableStateOf(20) }
+  val placeholderPoolSize = 30
   RefreshableLazyList(
     isVertical,
     itemsBefore = itemsBefore,
     itemsAfter = itemsAfter,
     onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
       state.onUserScroll(localFirstVisibleItemIndex, localLastVisibleItemIndex)
-
-      val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
-      val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
-      // We only ever want to increase the pool size.
-      if (placeholderPoolSize < proposedPlaceholderPoolSize) {
-        placeholderPoolSize = proposedPlaceholderPoolSize
-      }
     },
     refreshing = refreshing,
     onRefresh = onRefresh,
diff --git a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt
index 1c5c102476..882186c704 100644
--- a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt
+++ b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt
@@ -72,6 +72,14 @@ internal open class ViewLazyList private constructor(
   override val value: View get() = recyclerView
 
   private val processor = object : LazyListUpdateProcessor<ViewHolder, View>() {
+    override fun createPlaceholder(original: View): View {
+      return object : View(value.context) {
+        override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+          setMeasuredDimension(original.width, original.height)
+        }
+      }
+    }
+
     override fun insertRows(index: Int, count: Int) {
       adapter.notifyItemRangeInserted(index, count)
     }
diff --git a/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt
index 3b1c6fe98a..1fee831343 100644
--- a/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt
+++ b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.lazylayout.widget
 
+import app.cash.redwood.Modifier
 import app.cash.redwood.widget.Widget
 
 /**
@@ -43,6 +44,12 @@ public abstract class LazyListUpdateProcessor<V : Any, W : Any> {
   /** Pool of placeholder widgets. */
   private val placeholdersQueue = ArrayDeque<Widget<W>>()
 
+  /**
+   * The first placeholder ever returned. We use it to choose measured dimensions for created
+   * placeholders if the pool ever runs out.
+   */
+  private var firstPlaceholder: Widget<W>? = null
+
   /** Loaded items that may or may not have a view bound. */
   private var loadedItems = mutableListOf<Binding<V, W>>()
 
@@ -57,6 +64,7 @@ public abstract class LazyListUpdateProcessor<V : Any, W : Any> {
   /** We expect placeholders to be added early and to never change. */
   public val placeholder: Widget.Children<W> = object : Widget.Children<W> {
     override fun insert(index: Int, widget: Widget<W>) {
+      if (firstPlaceholder == null) firstPlaceholder = widget
       placeholdersQueue += widget
     }
 
@@ -253,7 +261,7 @@ public abstract class LazyListUpdateProcessor<V : Any, W : Any> {
 
     // We have a binding. Give it loaded content.
     require(placeholder.isPlaceholder)
-    placeholdersQueue += placeholder.content!!
+    recyclePlaceholder(placeholder.content!!)
     placeholder.isPlaceholder = false
     placeholder.content = loadedContent
     return placeholder
@@ -313,10 +321,35 @@ public abstract class LazyListUpdateProcessor<V : Any, W : Any> {
   }
 
   private fun takePlaceholder(): Widget<W> {
-    return placeholdersQueue.removeFirstOrNull()
+    val result = placeholdersQueue.removeFirstOrNull()
+    if (result != null) return result
+
+    val created = createPlaceholder(firstPlaceholder!!.value)
       ?: throw IllegalStateException("no more placeholders!")
+
+    return SizeOnlyPlaceholder(created)
+  }
+
+  private fun recyclePlaceholder(placeholder: Widget<W>) {
+    if (placeholder !is SizeOnlyPlaceholder) {
+      placeholdersQueue += placeholder
+    }
+  }
+
+  private class SizeOnlyPlaceholder<W : Any>(
+    override val value: W,
+  ) : Widget<W> {
+    override var modifier: Modifier = Modifier
   }
 
+  /**
+   * Returns an empty widget with the same dimensions as [original].
+   *
+   * @param original a placeholder provided by the guest code. It is a live view that is currently
+   *     in a layout.
+   */
+  protected open fun createPlaceholder(original: W): W? = null
+
   protected abstract fun insertRows(index: Int, count: Int)
 
   protected abstract fun deleteRows(index: Int, count: Int)
@@ -383,7 +416,7 @@ public abstract class LazyListUpdateProcessor<V : Any, W : Any> {
         if (itemsAfterIndex != -1) processor.itemsAfter.set(itemsAfterIndex, null)
 
         // When a placeholder is reused, recycle its widget.
-        processor.placeholdersQueue += content!!
+        processor.recyclePlaceholder(content!!)
       }
     }
   }
diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
index 03c34ee399..39a6a39380 100644
--- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
+++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
@@ -104,6 +104,10 @@ class EmojiSearchActivity : ComponentActivity() {
     private var success = true
     private var snackbar: Snackbar? = null
 
+    override fun uncaughtException(exception: Throwable) {
+      Log.e("Treehouse", "uncaughtException", exception)
+    }
+
     override fun codeLoadFailed(exception: Exception, startValue: Any?) {
       Log.w("Treehouse", "codeLoadFailed", exception)
       if (success) {