From d1d4e0438d516db914795638bdcb78d919e32065 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Tue, 10 Oct 2023 13:04:36 -0400 Subject: [PATCH] Refactor processor to support externally-created views (#1569) * Rename LazyListUpdateProcessor.Cell to Binding This new name fits its use better. * Refactor processor to support externally-created views * Don't extend both Objective-C and Kotlin types --- .../lazylayout/uiview/UIViewLazyList.kt | 84 ++++---- .../widget/LazyListUpdateProcessor.kt | 184 +++++++++--------- .../redwood/lazylayout/widget/SparseList.kt | 10 + .../lazylayout/widget/FakeProcessor.kt | 44 ++--- 4 files changed, 167 insertions(+), 155 deletions(-) diff --git a/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UIViewLazyList.kt b/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UIViewLazyList.kt index bdc6319ebe..5cc3e063a6 100644 --- a/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UIViewLazyList.kt +++ b/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UIViewLazyList.kt @@ -27,6 +27,7 @@ import app.cash.redwood.layout.api.CrossAxisAlignment import app.cash.redwood.lazylayout.api.ScrollItemIndex import app.cash.redwood.lazylayout.widget.LazyList import app.cash.redwood.lazylayout.widget.LazyListUpdateProcessor +import app.cash.redwood.lazylayout.widget.LazyListUpdateProcessor.Binding import app.cash.redwood.lazylayout.widget.RefreshableLazyList import app.cash.redwood.ui.Margin import app.cash.redwood.widget.ChangeListener @@ -73,25 +74,6 @@ internal open class UIViewLazyList( protected var onViewportChanged: ((firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit)? = null private val processor = object : LazyListUpdateProcessor() { - override fun createCell( - cell: Cell, - widget: Widget, - index: Int, - ): LazyListContainerCell { - val result = tableView.dequeueReusableCellWithIdentifier( - identifier = reuseIdentifier, - forIndexPath = NSIndexPath.indexPathForItem(index.convert(), 0.convert()), - ) as LazyListContainerCell - require(result.cell == null) - result.cell = cell - result.setWidget(widget.value) - return result - } - - override fun setWidget(cell: LazyListContainerCell, widget: Widget) { - cell.setWidget(widget.value) - } - override fun insertRows(index: Int, count: Int) { // TODO(jwilson): pass a range somehow when 'count' is large? tableView.insertRowsAtIndexPaths( @@ -107,6 +89,10 @@ internal open class UIViewLazyList( UITableViewRowAnimationNone, ) } + + override fun setContent(view: LazyListContainerCell, content: Widget) { + view.content = content + } } override val placeholder: Widget.Children = processor.placeholder @@ -125,7 +111,26 @@ internal open class UIViewLazyList( override fun tableView( tableView: UITableView, cellForRowAtIndexPath: NSIndexPath, - ) = processor.getCell(cellForRowAtIndexPath.item.toInt()) + ): LazyListContainerCell { + val index = cellForRowAtIndexPath.item.toInt() + return processor.getOrCreateView(index) { binding -> + createView(tableView, binding, index) + } + } + + private fun createView( + tableView: UITableView, + binding: Binding, + index: Int, + ): LazyListContainerCell { + val result = tableView.dequeueReusableCellWithIdentifier( + identifier = reuseIdentifier, + forIndexPath = NSIndexPath.indexPathForItem(index.convert(), 0.convert()), + ) as LazyListContainerCell + require(result.binding == null) + result.binding = binding + return result + } } private val tableViewDelegate: UITableViewDelegateProtocol = @@ -214,8 +219,18 @@ internal class LazyListContainerCell( style: UITableViewCellStyle, reuseIdentifier: String?, ) : UITableViewCell(style, reuseIdentifier) { - internal var cell: LazyListUpdateProcessor.Cell? = null - internal var widgetView: UIView? = null + internal var binding: Binding? = null + internal var content: Widget? = null + set(value) { + field = value + + removeAllSubviews() + if (value != null) { + contentView.addSubview(value.value) + contentView.translatesAutoresizingMaskIntoConstraints = false + } + setNeedsLayout() + } override fun initWithStyle( style: UITableViewCellStyle, @@ -234,43 +249,34 @@ internal class LazyListContainerCell( // Confirm the cell is bound when it's about to be displayed. if (superview == null && newSuperview != null) { - require(cell!!.isBound) { "about to display a cell that isn't bound!" } + require(binding!!.isBound) { "about to display a cell that isn't bound!" } } // Unbind the cell when its view is detached from the table. if (superview != null && newSuperview == null) { removeAllSubviews() - cell?.unbind() - cell = null + binding?.unbind() + binding = null } } override fun prepareForReuse() { super.prepareForReuse() removeAllSubviews() - cell?.unbind() - cell = null - } - - fun setWidget(widgetView: UIView) { - this.widgetView = widgetView - - removeAllSubviews() - contentView.addSubview(widgetView) - contentView.translatesAutoresizingMaskIntoConstraints = false - setNeedsLayout() + binding?.unbind() + binding = null } override fun layoutSubviews() { super.layoutSubviews() - val widgetView = this.widgetView ?: return - widgetView.setFrame(bounds) + val content = this.content ?: return + content.value.setFrame(bounds) contentView.setFrame(bounds) } override fun sizeThatFits(size: CValue): CValue { - return widgetView?.sizeThatFits(size) ?: return super.sizeThatFits(size) + return content?.value?.sizeThatFits(size) ?: return super.sizeThatFits(size) } private fun removeAllSubviews() { 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 fcd7cf7fce..077817c768 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 @@ -35,16 +35,16 @@ import app.cash.redwood.widget.Widget * * This class keeps track of the two windows, and of firing precise updates as the window changes. */ -public abstract class LazyListUpdateProcessor { - - /** Loaded cells that may or may not have UI attached. */ - private var loadedCells = mutableListOf>() +public abstract class LazyListUpdateProcessor { /** Pool of placeholder widgets. */ private val placeholdersQueue = ArrayDeque>() - private val itemsBefore = SparseList?>() - private var itemsAfter = SparseList?>() + /** Loaded items that may or may not have a view bound. */ + private var loadedItems = mutableListOf>() + + private val itemsBefore = SparseList?>() + private var itemsAfter = SparseList?>() /** These updates will all be processed in batch in [onEndChanges]. */ private var newItemsBefore = 0 @@ -101,7 +101,7 @@ public abstract class LazyListUpdateProcessor { } public val size: Int - get() = itemsBefore.size + loadedCells.size + itemsAfter.size + get() = itemsBefore.size + loadedItems.size + itemsAfter.size public fun itemsBefore(itemsBefore: Int) { this.newItemsBefore = itemsBefore @@ -125,7 +125,7 @@ public abstract class LazyListUpdateProcessor { val toPromoteCount = minOf(edit.widgets.size, itemsBefore.size - newItemsBefore) for (i in edit.widgets.size - 1 downTo edit.widgets.size - toPromoteCount) { val placeholder = itemsBefore.removeLast() - loadedCells.add(0, placeholderToLoaded(placeholder, edit.widgets[i])) + loadedItems.add(0, placeholderToLoaded(placeholder, edit.widgets[i])) edit.widgets.removeAt(i) } } else if ( @@ -136,7 +136,7 @@ public abstract class LazyListUpdateProcessor { // The before window is growing. Demote loaded cells into placeholders. val toDemoteCount = minOf(edit.count, newItemsBefore - itemsBefore.size) for (i in 0 until toDemoteCount) { - val removed = loadedCells.removeAt(0) + val removed = loadedItems.removeAt(0) itemsBefore.add(loadedToPlaceholder(removed)) edit.count-- } @@ -146,25 +146,25 @@ public abstract class LazyListUpdateProcessor { if ( newItemsAfter < itemsAfter.size && edit is Edit.Insert && - edit.index == loadedCells.size + edit.index == loadedItems.size ) { // The after window is shrinking. Promote inserts into loads. val toPromoteCount = minOf(edit.widgets.size, itemsAfter.size - newItemsAfter) for (i in 0 until toPromoteCount) { val widget = edit.widgets.removeFirst() val placeholder = itemsAfter.removeAt(0) - loadedCells.add(placeholderToLoaded(placeholder, widget)) + loadedItems.add(placeholderToLoaded(placeholder, widget)) edit.index++ } } else if ( newItemsAfter > itemsAfter.size && edit is Edit.Remove && - edit.index + edit.count == loadedCells.size + edit.index + edit.count == loadedItems.size ) { // The after window is growing. Demote loaded cells into placeholders. val toDemoteCount = minOf(edit.count, newItemsAfter - itemsAfter.size) for (i in edit.count - 1 downTo edit.count - toDemoteCount) { - val removed = loadedCells.removeLast() + val removed = loadedItems.removeLast() itemsAfter.add(0, loadedToPlaceholder(removed)) edit.count-- } @@ -175,7 +175,10 @@ public abstract class LazyListUpdateProcessor { is Edit.Insert -> { for (i in 0 until edit.widgets.size) { val index = itemsBefore.size + edit.index + i - loadedCells.add(edit.index + i, Cell(this, edit.widgets[i])) + val binding = Binding(this).apply { + content = edit.widgets[i] + } + loadedItems.add(edit.index + i, binding) // Publish a structural change. insertRows(index, 1) @@ -190,7 +193,7 @@ public abstract class LazyListUpdateProcessor { is Edit.Remove -> { for (i in edit.index until edit.index + edit.count) { val index = itemsBefore.size + edit.index - loadedCells.removeAt(edit.index) + loadedItems.removeAt(edit.index) // Publish a structural change. deleteRows(index, 1) @@ -218,14 +221,14 @@ public abstract class LazyListUpdateProcessor { newItemsAfter < itemsAfter.size -> { // Shrink the after window. val delta = itemsAfter.size - newItemsAfter - val index = itemsBefore.size + loadedCells.size + itemsAfter.size - delta + val index = itemsBefore.size + loadedItems.size + itemsAfter.size - delta itemsAfter.removeRange(itemsAfter.size - delta, itemsAfter.size) deleteRows(index, delta) } newItemsAfter > itemsAfter.size -> { // Grow the after window. val delta = newItemsAfter - itemsAfter.size - val index = itemsBefore.size + loadedCells.size + itemsAfter.size + val index = itemsBefore.size + loadedItems.size + itemsAfter.size itemsAfter.addNulls(itemsAfter.size, delta) insertRows(index, delta) } @@ -235,78 +238,75 @@ public abstract class LazyListUpdateProcessor { } private fun placeholderToLoaded( - placeholder: Cell?, - widget: Widget, - ): Cell { - // No placeholder for this index. Create a new cell. + placeholder: Binding?, + loadedContent: Widget, + ): Binding { + // No binding for this index. Create one. if (placeholder == null) { - return Cell(this, widget) + return Binding(this).apply { + content = loadedContent + } } - // We have a placeholder. Promote it to a loaded cell. + // We have a binding. Give it loaded content. require(placeholder.isPlaceholder) - placeholdersQueue += placeholder.widget + placeholdersQueue += placeholder.content!! placeholder.isPlaceholder = false - placeholder.widget = widget - setWidget(placeholder.view!!, widget) + placeholder.content = loadedContent return placeholder } - private fun loadedToPlaceholder(loaded: Cell): Cell? { + private fun loadedToPlaceholder(loaded: Binding): Binding? { require(!loaded.isPlaceholder) - // If there's no UI for this cell, we're done. - val ui = loaded.view ?: return null + // If there's no view for this binding, we're done. + if (!loaded.isBound) return null - // Replace the loaded UI with a placeholder. - val widget = takePlaceholder() - setWidget(ui, widget) - loaded.widget = widget + // Show the placeholder in the bound view. + val placeholderContent = loaded.processor.takePlaceholder() + loaded.content = placeholderContent loaded.isPlaceholder = true return loaded } - public fun getCell(index: Int): C { - return when { - index < itemsBefore.size -> getOrCreatePlaceholder(itemsBefore, index, index) - index < itemsBefore.size + loadedCells.size -> getLoadedCell(index) - else -> getOrCreatePlaceholder(itemsAfter, index - itemsBefore.size - loadedCells.size, index) - } - } + public fun getOrCreateView( + index: Int, + createView: (binding: Binding) -> V, + ): V { + val binding = requireBindingAtIndex(index) - private fun getLoadedCell(index: Int): C { - val result = loadedCells[index - itemsBefore.size] - - var view = result.view + var view = binding.view if (view == null) { - // Bind the cell to this view. - view = createCell(result, result.widget, index) - result.bind(view) + view = createView(binding) + binding.bind(view) } return view } - private fun getOrCreatePlaceholder( - placeholders: SparseList?>, - placeholderIndex: Int, - cellIndex: Int, - ): C { - var result = placeholders[placeholderIndex] - - // Return an existing placeholder. - if (result != null) return result.view!! + public fun bind(index: Int, view: V): Binding { + return requireBindingAtIndex(index).apply { + bind(view) + } + } - // Create a new placeholder cell and bind it to the view. - result = Cell( - processor = this, - widget = takePlaceholder(), + /** + * Callers must immediately call [Binding.bind] on the result if it isn't already bound. Otherwise + * we could leak placeholder bindings. + */ + private fun requireBindingAtIndex(index: Int): Binding { + fun createBinding() = Binding( + processor = this@LazyListUpdateProcessor, isPlaceholder = true, - ) - val view = createCell(result, result.widget, cellIndex) - result.bind(view) - placeholders.set(placeholderIndex, result) - return view + ).apply { + content = takePlaceholder() + } + + return when { + index < itemsBefore.size -> itemsBefore.getOrCreate(index, ::createBinding) + index < itemsBefore.size + loadedItems.size -> loadedItems[index - itemsBefore.size] + else -> itemsAfter.getOrCreate(index - itemsBefore.size - loadedItems.size, ::createBinding) + } } private fun takePlaceholder(): Widget { @@ -314,53 +314,53 @@ public abstract class LazyListUpdateProcessor { ?: throw IllegalStateException("no more placeholders!") } - protected abstract fun createCell(cell: Cell, widget: Widget, index: Int): C - - protected abstract fun setWidget(cell: C, widget: Widget) - protected abstract fun insertRows(index: Int, count: Int) protected abstract fun deleteRows(index: Int, count: Int) + protected abstract fun setContent(view: V, content: Widget) + /** - * This class keeps track of whether a cell is bound to an on-screen UI. + * Binds a UI-managed view to model-managed content. * - * While it's bound to the UI, its contents can change: + * While view is bound to content, that content can change: * - * - A placeholder can be swapped for a loaded widget, if the loaded window moves to include - * this cell. - * - Symmetrically, a loaded widget can be swapped for a placeholder, if the loaded window moves - * to exclude this cell. + * - A placeholder can be swapped for loaded content. This happens when the model's loaded window + * moves to include the view. + * - Symmetrically, loaded content can be swapped for placeholder content, if the loaded window + * moves to exclude the view. * - * If it currently holds a placeholder, this placeholder must be released to the processor's - * placeholder queue when it is no longer needed. This will occur if cell is reused (due to view - * recycling), or because it is discarded (due to the table discarding it). This class assumes - * that a cell that is discarded will never be bound again. + * If it currently holds placeholder content, that placeholder content must be released to the + * processor's placeholder queue when it is no longer needed. This will occur if view is reused + * (due to view recycling), or because it is discarded (due to the view discarding it). This class + * assumes that a view that is discarded will never be bound again. */ - public class Cell internal constructor( - internal val processor: LazyListUpdateProcessor, - internal var widget: Widget, + public class Binding internal constructor( + internal val processor: LazyListUpdateProcessor, internal var isPlaceholder: Boolean = false, ) { - public var view: C? = null + internal var view: V? = null private set - private var bindCount = 0 - private var unbindCount = 0 + internal var content: Widget? = null + set(value) { + field = value + val view = this.view + if (view != null) processor.setContent(view, value!!) + } public val isBound: Boolean - get() = bindCount > unbindCount + get() = view != null - public fun bind(view: C) { - require(bindCount == unbindCount) { "already bound" } + internal fun bind(view: V) { + require(this.view == null) { "already bound" } - bindCount++ this.view = view + processor.setContent(view, content!!) } public fun unbind() { - if (bindCount == unbindCount) return - unbindCount++ + if (view == null) return // Detach the display. view = null @@ -374,7 +374,7 @@ public abstract class LazyListUpdateProcessor { if (itemsAfterIndex != -1) processor.itemsAfter.set(itemsAfterIndex, null) // When a placeholder is reused, recycle its widget. - processor.placeholdersQueue += widget + processor.placeholdersQueue += content!! } } } diff --git a/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt index 570acea35a..5eaf130bb4 100644 --- a/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt +++ b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt @@ -46,6 +46,16 @@ internal class SparseList : AbstractList() { } } + inline fun getOrCreate(index: Int, create: () -> T & Any): T & Any { + var result = get(index) + + if (result != null) return result + + result = create() + set(index, result) + return result + } + fun removeLast(): T? { return removeAt(size - 1) } diff --git a/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt index f5897c12a4..bf95b4aaf9 100644 --- a/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt +++ b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt @@ -26,19 +26,10 @@ class FakeProcessor : LazyListUpdateProcessor( private var scrollWindowOffset = 0 private val scrollWindowCells = mutableListOf() - override fun createCell( - cell: Cell, - widget: Widget, - index: Int, - ): StringCell { - return StringCell(cell, widget) - } - - override fun setWidget( - cell: StringCell, - widget: Widget, - ) { - cell.widget = widget + private fun getView(index: Int): StringCell { + return getOrCreateView(index) { binding -> + StringCell(binding) + } } override fun insertRows(index: Int, count: Int) { @@ -54,8 +45,8 @@ class FakeProcessor : LazyListUpdateProcessor( } else if (i < scrollWindowLimit) { // Insert a new cell into the scroll window. We remove the last element to preserve the // scroll window's size. - scrollWindowCells.add(i - scrollWindowOffset, getCell(i)) - scrollWindowCells.removeLast().cell.unbind() + scrollWindowCells.add(i - scrollWindowOffset, getView(i)) + scrollWindowCells.removeLast().binding.unbind() } } } @@ -73,7 +64,7 @@ class FakeProcessor : LazyListUpdateProcessor( scrollWindowOffset-- } else if (index < scrollWindowLimit) { // Delete a cell from the scroll window. We'll replenish the window below. - scrollWindowCells.removeAt(index - scrollWindowOffset).cell.unbind() + scrollWindowCells.removeAt(index - scrollWindowOffset).binding.unbind() } } @@ -83,10 +74,14 @@ class FakeProcessor : LazyListUpdateProcessor( scrollWindowCells.size < originalScrollWindowSize && scrollWindowOffset + scrollWindowCells.size < dataSize ) { - scrollWindowCells.add(getCell(scrollWindowOffset + scrollWindowCells.size)) + scrollWindowCells.add(getView(scrollWindowOffset + scrollWindowCells.size)) } } + override fun setContent(view: StringCell, content: Widget) { + view.content = content + } + fun scrollTo(offset: Int, count: Int) { val oldWindowCells = ArrayDeque(scrollWindowCells) var oldWindowCellsOffset = scrollWindowOffset @@ -96,7 +91,7 @@ class FakeProcessor : LazyListUpdateProcessor( // Recycle old cells that precede the new window. while (oldWindowCellsOffset < offset) { - if (oldWindowCells.isNotEmpty()) oldWindowCells.removeFirst().cell.unbind() + if (oldWindowCells.isNotEmpty()) oldWindowCells.removeFirst().binding.unbind() oldWindowCellsOffset++ } @@ -106,13 +101,13 @@ class FakeProcessor : LazyListUpdateProcessor( scrollWindowCells += oldWindowCells.removeFirst() oldWindowCellsOffset++ } else { - scrollWindowCells += getCell(i) + scrollWindowCells += getView(i) } } // Recycle old cells that are beyond the new window. while (oldWindowCells.isNotEmpty()) { - oldWindowCells.removeFirst().cell.unbind() + oldWindowCells.removeFirst().binding.unbind() } } @@ -130,12 +125,13 @@ class FakeProcessor : LazyListUpdateProcessor( }, separator = " ", ) { - it.widget.value + it.content!!.value } } class StringCell( - val cell: Cell, - var widget: Widget, - ) + val binding: Binding, + ) { + var content: Widget? = null + } }