From aad3385773fdfbd46d3105abfcf31116be108a4d Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Thu, 28 Sep 2023 21:12:15 -0400 Subject: [PATCH] Update UITableView to track changes precisely Previously we called reloadData() whenever anything changed in the underlying model. With this change we fire precise events to the UI when inserts and removes happen in the source LazyList. This attempts to coalesce changes to itemsBefore() and itemsAfter() with adjacent changes and the beginning and end of the items list. To implement this the changes are buffered into an intermediate model before they are applied. When a cell changes from being a placeholder to a loaded item, or the opposite, this does a cell content change without firing an event through the UITableView. To implement this we must track which cells are currently displaying placeholders. This PR includes a new datastructure, SparseList, to implement this tracking. This PR also changes the number of placeholders that guest code offers to host code. I found experimentally that 20 placeholders was not enough for UITableView. This PR changes LazyList from UICollectionView to UITableView. This change was potentially unnecessary, though UITableView has fewer features that we don't need. --- .../redwood/lazylayout/compose/LazyList.kt | 4 +- .../lazylayout/compose/LazyListTest.kt | 1 - .../UICollectionViewListUpdateCallback.kt | 32 -- .../lazylayout/uiview/UIViewLazyList.kt | 261 +++++++----- .../RecyclerViewAdapterListUpdateCallback.kt | 1 - redwood-lazylayout-widget/build.gradle | 6 + .../widget/LazyListUpdateProcessor.kt | 388 ++++++++++++++++++ .../redwood/lazylayout/widget/SparseList.kt | 155 +++++++ .../lazylayout/widget/FakeProcessor.kt | 141 +++++++ .../widget/LazyListUpdateProcessorTest.kt | 380 +++++++++++++++++ .../lazylayout/widget/SparseListTest.kt | 294 +++++++++++++ 11 files changed, 1522 insertions(+), 141 deletions(-) delete mode 100644 redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UICollectionViewListUpdateCallback.kt create mode 100644 redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt create mode 100644 redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt create mode 100644 redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt create mode 100644 redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessorTest.kt create mode 100644 redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/SparseListTest.kt 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 308fc87ac0..dc0ade723e 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 @@ -49,14 +49,14 @@ internal fun LazyList( val itemsBefore = remember(state.firstVisibleItemIndex) { (state.firstVisibleItemIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0) } val itemsAfter = remember(lastVisibleItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastVisibleItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) } val scrollItemIndex = remember(state.scrollToItemTriggeredId) { ScrollItemIndex(state.scrollToItemTriggeredId, state.firstVisibleItemIndex) } - var placeholderPoolSize by remember { mutableStateOf(20) } + var placeholderPoolSize by remember { mutableStateOf(30) } LazyList( isVertical, itemsBefore = itemsBefore, itemsAfter = itemsAfter, onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex -> val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex - val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2 + val proposedPlaceholderPoolSize = visibleItemCount * 2 // We only ever want to increase the pool size. if (placeholderPoolSize < proposedPlaceholderPoolSize) { placeholderPoolSize = proposedPlaceholderPoolSize diff --git a/redwood-lazylayout-compose/src/commonTest/kotlin/app/cash/redwood/lazylayout/compose/LazyListTest.kt b/redwood-lazylayout-compose/src/commonTest/kotlin/app/cash/redwood/lazylayout/compose/LazyListTest.kt index d9c97a55fe..213a3b52a7 100644 --- a/redwood-lazylayout-compose/src/commonTest/kotlin/app/cash/redwood/lazylayout/compose/LazyListTest.kt +++ b/redwood-lazylayout-compose/src/commonTest/kotlin/app/cash/redwood/lazylayout/compose/LazyListTest.kt @@ -22,7 +22,6 @@ import app.cash.redwood.layout.api.CrossAxisAlignment import app.cash.redwood.layout.widget.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.api.ScrollItemIndex import app.cash.redwood.lazylayout.widget.LazyListValue -import app.cash.redwood.lazylayout.widget.ListUpdateCallback import app.cash.redwood.lazylayout.widget.RedwoodLazyLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.widget.RedwoodLazyLayoutWidgetFactory import app.cash.redwood.lazylayout.widget.WindowedLazyList diff --git a/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UICollectionViewListUpdateCallback.kt b/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UICollectionViewListUpdateCallback.kt deleted file mode 100644 index cfae4aa2ed..0000000000 --- a/redwood-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/lazylayout/uiview/UICollectionViewListUpdateCallback.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2023 Square, Inc. - * - * 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 app.cash.redwood.lazylayout.uiview - -import app.cash.redwood.lazylayout.widget.ListUpdateCallback -import platform.UIKit.UICollectionView - -internal class UICollectionViewListUpdateCallback( - private val collectionView: UICollectionView, -) : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - } - - override fun onMoved(fromPosition: Int, toPosition: Int, count: Int) { - } - - override fun onRemoved(position: Int, count: Int) { - } -} 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 fb7a5c0294..65b76db094 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 @@ -25,39 +25,35 @@ import app.cash.redwood.Modifier import app.cash.redwood.layout.api.Constraint 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.RefreshableLazyList -import app.cash.redwood.lazylayout.widget.WindowedChildren -import app.cash.redwood.lazylayout.widget.WindowedLazyList import app.cash.redwood.ui.Margin import app.cash.redwood.widget.ChangeListener -import app.cash.redwood.widget.MutableListChildren import app.cash.redwood.widget.Widget import kotlinx.cinterop.CValue import kotlinx.cinterop.ObjCClass +import kotlinx.cinterop.convert import kotlinx.cinterop.readValue -import kotlinx.cinterop.useContents -import platform.CoreGraphics.CGFloat import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectZero -import platform.CoreGraphics.CGSizeMake +import platform.CoreGraphics.CGSize import platform.Foundation.NSIndexPath import platform.Foundation.classForCoder -import platform.UIKit.UICollectionView -import platform.UIKit.UICollectionViewCell -import platform.UIKit.UICollectionViewDataSourceProtocol -import platform.UIKit.UICollectionViewDelegateFlowLayoutProtocol -import platform.UIKit.UICollectionViewFlowLayout -import platform.UIKit.UICollectionViewFlowLayoutAutomaticSize -import platform.UIKit.UICollectionViewLayout -import platform.UIKit.UICollectionViewLayoutAttributes -import platform.UIKit.UICollectionViewScrollDirection.UICollectionViewScrollDirectionHorizontal -import platform.UIKit.UICollectionViewScrollDirection.UICollectionViewScrollDirectionVertical -import platform.UIKit.UICollectionViewScrollPositionTop import platform.UIKit.UIColor import platform.UIKit.UIControlEventValueChanged import platform.UIKit.UIEdgeInsetsMake import platform.UIKit.UIRefreshControl import platform.UIKit.UIScrollView +import platform.UIKit.UITableView +import platform.UIKit.UITableViewAutomaticDimension +import platform.UIKit.UITableViewCell +import platform.UIKit.UITableViewCellSeparatorStyle.UITableViewCellSeparatorStyleNone +import platform.UIKit.UITableViewCellStyle +import platform.UIKit.UITableViewDataSourceProtocol +import platform.UIKit.UITableViewDelegateProtocol +import platform.UIKit.UITableViewRowAnimationNone +import platform.UIKit.UITableViewScrollPosition import platform.UIKit.UIView import platform.UIKit.indexPathForItem import platform.UIKit.item @@ -65,65 +61,78 @@ import platform.darwin.NSInteger import platform.darwin.NSObject internal open class UIViewLazyList( - private var collectionViewFlowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout().apply { - estimatedItemSize = UICollectionViewFlowLayoutAutomaticSize.readValue() - }, - internal val collectionView: UICollectionView = UICollectionView( + internal val tableView: UITableView = UITableView( CGRectZero.readValue(), - collectionViewFlowLayout, ), -) : WindowedLazyList(UICollectionViewListUpdateCallback(collectionView)), ChangeListener { +) : LazyList, ChangeListener { + override var modifier: Modifier = Modifier - // Fetch the item relative to the entire collection view - private fun WindowedChildren.itemForGlobalIndex(index: Int): Widget { - return get(index) ?: placeholder[index % placeholder.size] - } + override val value: UIView + get() = tableView + + 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 + } - private val collectionViewDataSource: UICollectionViewDataSourceProtocol = - object : NSObject(), UICollectionViewDataSourceProtocol { + override fun setWidget(cell: LazyListContainerCell, widget: Widget) { + cell.setWidget(widget.value) + } - override fun collectionView( - collectionView: UICollectionView, - numberOfItemsInSection: NSInteger, - ): NSInteger { - return items.size.toLong() - } + override fun insertRows(index: Int, count: Int) { + // TODO(jwilson): pass a range somehow when 'count' is large? + tableView.insertRowsAtIndexPaths( + (index until index + count).map { NSIndexPath.indexPathForItem(it.convert(), 0) }, + UITableViewRowAnimationNone, + ) + } - override fun collectionView( - collectionView: UICollectionView, - cellForItemAtIndexPath: NSIndexPath, - ): UICollectionViewCell { - val cell = collectionView.dequeueReusableCellWithReuseIdentifier( - identifier = reuseIdentifier, - forIndexPath = cellForItemAtIndexPath, - ) as LazyListContainerCell + override fun deleteRows(index: Int, count: Int) { + // TODO(jwilson): pass a range somehow when 'count' is large? + tableView.deleteRowsAtIndexPaths( + (index until index + count).map { NSIndexPath.indexPathForItem(it.convert(), 0) }, + UITableViewRowAnimationNone, + ) + } + } - cell.collectionViewFrame = collectionView.frame - cell.collectionViewFlowLayout = collectionViewFlowLayout + override val placeholder: Widget.Children = processor.placeholder - val widget = items.itemForGlobalIndex(cellForItemAtIndexPath.item.toInt()) - cell.set(widget.value) + override val items: Widget.Children = processor.items - return cell - } - } + private val dataSource = object : NSObject(), UITableViewDataSourceProtocol { + override fun tableView( + tableView: UITableView, + numberOfRowsInSection: NSInteger, + ) = processor.size.toLong() - private val collectionViewDelegate: UICollectionViewDelegateFlowLayoutProtocol = - object : NSObject(), UICollectionViewDelegateFlowLayoutProtocol { - override fun collectionView( - collectionView: UICollectionView, - layout: UICollectionViewLayout, - minimumLineSpacingForSectionAtIndex: NSInteger, - ): CGFloat { - return 0.0 - } + override fun tableView( + tableView: UITableView, + cellForRowAtIndexPath: NSIndexPath, + ) = processor.getCell(cellForRowAtIndexPath.item.toInt()) + } + private val tableViewDelegate: UITableViewDelegateProtocol = + object : NSObject(), UITableViewDelegateProtocol { override fun scrollViewDidScroll(scrollView: UIScrollView) { - val visibleIndexPaths = collectionView.indexPathsForVisibleItems() + val visibleIndexPaths = tableView.indexPathsForVisibleRows ?: return if (visibleIndexPaths.isNotEmpty()) { // TODO: Optimize this for less operations - updateViewport( + onViewportChanged?.invoke( visibleIndexPaths.minOf { (it as NSIndexPath).item.toInt() }, visibleIndexPaths.maxOf { (it as NSIndexPath).item.toInt() }, ) @@ -131,27 +140,29 @@ internal open class UIViewLazyList( } } - override val placeholder = MutableListChildren() - init { - collectionView.apply { - dataSource = collectionViewDataSource - delegate = collectionViewDelegate + tableView.apply { + dataSource = this@UIViewLazyList.dataSource + delegate = tableViewDelegate prefetchingEnabled = true + rowHeight = UITableViewAutomaticDimension + estimatedRowHeight = 44.0 + separatorStyle = UITableViewCellSeparatorStyleNone registerClass( - LazyListContainerCell(CGRectZero.readValue()).classForCoder() as ObjCClass?, - reuseIdentifier, + cellClass = LazyListContainerCell(UITableViewCellStyle.UITableViewCellStyleDefault, reuseIdentifier) + .initWithFrame(CGRectZero.readValue()).classForCoder() as ObjCClass?, + forCellReuseIdentifier = reuseIdentifier, ) } } + final override fun onViewportChanged(onViewportChanged: (Int, Int) -> Unit) { + this.onViewportChanged = onViewportChanged + } + override fun isVertical(isVertical: Boolean) { - collectionViewFlowLayout.scrollDirection = if (isVertical) { - UICollectionViewScrollDirectionVertical - } else { - UICollectionViewScrollDirectionHorizontal - } + // TODO: support horizontal LazyLists. } // TODO Dynamically update width and height of UIViewLazyList when set @@ -160,7 +171,12 @@ internal open class UIViewLazyList( override fun height(height: Constraint) {} override fun margin(margin: Margin) { - collectionView.contentInset = UIEdgeInsetsMake(margin.top.value, margin.start.value, margin.end.value, margin.bottom.value) + tableView.contentInset = UIEdgeInsetsMake( + margin.top.value, + margin.start.value, + margin.end.value, + margin.bottom.value, + ) } override fun crossAxisAlignment(crossAxisAlignment: CrossAxisAlignment) { @@ -168,61 +184,96 @@ internal open class UIViewLazyList( } override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) { - if (items.size > scrollItemIndex.index) { - collectionView.scrollToItemAtIndexPath( + if (scrollItemIndex.index < processor.size) { + tableView.scrollToRowAtIndexPath( NSIndexPath.indexPathForItem(scrollItemIndex.index.toLong(), 0), - UICollectionViewScrollPositionTop, + UITableViewScrollPosition.UITableViewScrollPositionTop, animated = false, ) } } - override var modifier: Modifier = Modifier + override fun itemsBefore(itemsBefore: Int) { + processor.itemsBefore(itemsBefore) + } + + override fun itemsAfter(itemsAfter: Int) { + processor.itemsAfter(itemsAfter) + } - override val value: UIView get() = collectionView override fun onEndChanges() { - collectionView.reloadData() + processor.onEndChanges() } } private const val reuseIdentifier = "LazyListContainerCell" -private class LazyListContainerCell(frame: CValue) : UICollectionViewCell(frame) { - lateinit var collectionViewFrame: CValue - lateinit var collectionViewFlowLayout: UICollectionViewFlowLayout +internal class LazyListContainerCell( + style: UITableViewCellStyle, + reuseIdentifier: String?, +) : UITableViewCell(style, reuseIdentifier) { + internal var cell: LazyListUpdateProcessor.Cell? = null + internal var widgetView: UIView? = null + + override fun initWithStyle( + style: UITableViewCellStyle, + reuseIdentifier: String?, + ): UITableViewCell = LazyListContainerCell(style, reuseIdentifier) + + override fun initWithFrame( + frame: CValue, + ): UITableViewCell { + return LazyListContainerCell(UITableViewCellStyle.UITableViewCellStyleDefault, null) + .apply { setFrame(frame) } + } + + override fun willMoveToSuperview(newSuperview: UIView?) { + super.willMoveToSuperview(newSuperview) + + // 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!" } + } + + // Unbind the cell when its view is detached from the table. + if (superview != null && newSuperview == null) { + removeAllSubviews() + cell?.unbind() + cell = null + } + } - private var widgetView: UIView? = null - override fun initWithFrame(frame: CValue): UICollectionViewCell = LazyListContainerCell(frame) override fun prepareForReuse() { super.prepareForReuse() - // clear out subviews here - this.contentView.subviews.forEach { - (it as UIView).removeFromSuperview() - } - widgetView = null + removeAllSubviews() + cell?.unbind() + cell = null } - fun set(widgetView: UIView) { + fun setWidget(widgetView: UIView) { this.widgetView = widgetView + + removeAllSubviews() contentView.addSubview(widgetView) - contentView.layoutIfNeeded() + contentView.translatesAutoresizingMaskIntoConstraints = false + setNeedsLayout() } override fun layoutSubviews() { super.layoutSubviews() - widgetView?.setFrame(this.contentView.bounds) + + val widgetView = this.widgetView ?: return + widgetView.setFrame(bounds) + contentView.setFrame(bounds) } - override fun preferredLayoutAttributesFittingAttributes( - layoutAttributes: UICollectionViewLayoutAttributes, - ): UICollectionViewLayoutAttributes { - val itemSize = widgetView!!.sizeThatFits(collectionViewFrame.useContents { size.readValue() }) - return layoutAttributes.apply { - size = if (collectionViewFlowLayout.scrollDirection == UICollectionViewScrollDirectionVertical) { - CGSizeMake(collectionViewFrame.useContents { size.width }, itemSize.useContents { height }) - } else { - CGSizeMake(itemSize.useContents { width }, collectionViewFrame.useContents { size.height }) - } + override fun sizeThatFits(size: CValue): CValue { + return widgetView?.sizeThatFits(size) ?: return super.sizeThatFits(size) + } + + private fun removeAllSubviews() { + contentView.subviews.forEach { + (it as UIView).removeFromSuperview() } } } @@ -253,8 +304,8 @@ internal class UIViewRefreshableLazyList : UIViewLazyList(), RefreshableLazyList this.onRefresh = onRefresh if (onRefresh != null) { - if (collectionView.refreshControl != refreshControl) { - collectionView.refreshControl = refreshControl + if (tableView.refreshControl != refreshControl) { + tableView.refreshControl = refreshControl } } else { refreshControl.removeFromSuperview() diff --git a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/RecyclerViewAdapterListUpdateCallback.kt b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/RecyclerViewAdapterListUpdateCallback.kt index 1e483f1dc1..d9bdc95117 100644 --- a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/RecyclerViewAdapterListUpdateCallback.kt +++ b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/RecyclerViewAdapterListUpdateCallback.kt @@ -16,7 +16,6 @@ package app.cash.redwood.lazylayout.view import androidx.recyclerview.widget.RecyclerView -import app.cash.redwood.lazylayout.widget.ListUpdateCallback internal class RecyclerViewAdapterListUpdateCallback( private val adapter: RecyclerView.Adapter<*>, diff --git a/redwood-lazylayout-widget/build.gradle b/redwood-lazylayout-widget/build.gradle index 37aba9fb8c..d288b08610 100644 --- a/redwood-lazylayout-widget/build.gradle +++ b/redwood-lazylayout-widget/build.gradle @@ -19,6 +19,12 @@ kotlin { api projects.redwoodLazylayoutApi } } + commonTest { + dependencies { + implementation libs.assertk + implementation libs.kotlin.test + } + } } } 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 new file mode 100644 index 0000000000..fcd7cf7fce --- /dev/null +++ b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessor.kt @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * 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 app.cash.redwood.lazylayout.widget + +import app.cash.redwood.widget.Widget + +/** + * Our lazy layouts can display arbitrarily large datasets. Instead of loading them all eagerly + * (which would be extremely slow!), it maintains a window of loaded items. + * + * As the user scrolls, the lazy layout shifts (and potentially resizes) its window in an attempt + * to maintain an illusion that all data is always loaded. If the user scrolls beyond what is + * loaded, a placeholder is displayed until the row is loaded. + * + * The net effect is that there are two windows into the dataset: + * + * - the window of what is loaded + * - the window of what the user is looking at + * + * To maintain the illusion that everything is loaded, the loaded data window should always contain + * the visible window. + * + * 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>() + + /** Pool of placeholder widgets. */ + private val placeholdersQueue = ArrayDeque>() + + private val itemsBefore = SparseList?>() + private var itemsAfter = SparseList?>() + + /** These updates will all be processed in batch in [onEndChanges]. */ + private var newItemsBefore = 0 + private var newItemsAfter = 0 + private val edits = mutableListOf>() + + /** We expect placeholders to be added early and to never change. */ + public val placeholder: Widget.Children = object : Widget.Children { + override fun insert(index: Int, widget: Widget) { + placeholdersQueue += widget + } + + override fun move(fromIndex: Int, toIndex: Int, count: Int) { + error("unexpected call") + } + + override fun remove(index: Int, count: Int) { + error("unexpected call") + } + + override fun onModifierUpdated() { + } + } + + /** Changes to this list are collected and processed in batch once all changes are received. */ + public val items: Widget.Children = object : Widget.Children { + override fun insert(index: Int, widget: Widget) { + val last = edits.lastOrNull() + if (last is Edit.Insert && index in last.index until last.index + last.widgets.size + 1) { + // Grow the preceding insert. This makes promotion logic easier. + last.widgets.add(index - last.index, widget) + } else { + edits += Edit.Insert(index, mutableListOf(widget)) + } + } + + override fun move(fromIndex: Int, toIndex: Int, count: Int) { + edits += Edit.Move(fromIndex, toIndex, count) + } + + override fun remove(index: Int, count: Int) { + val last = edits.lastOrNull() + if (last is Edit.Remove && index in last.index - count until last.index + 1) { + // Grow the preceding remove. This makes promotion logic easier. + if (index < last.index) last.index = index + last.count += count + } else { + edits += Edit.Remove(index, count) + } + } + + override fun onModifierUpdated() { + } + } + + public val size: Int + get() = itemsBefore.size + loadedCells.size + itemsAfter.size + + public fun itemsBefore(itemsBefore: Int) { + this.newItemsBefore = itemsBefore + } + + public fun itemsAfter(itemsAfter: Int) { + this.newItemsAfter = itemsAfter + } + + public fun onEndChanges() { + for (e in edits.indices) { + val edit = edits[e] + + // Attempt to absorb this change into the before range. This reduces structural updates. + if ( + newItemsBefore < itemsBefore.size && + edit is Edit.Insert && + edit.index == 0 + ) { + // The before window is shrinking. Promote inserts into loads. + 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])) + edit.widgets.removeAt(i) + } + } else if ( + newItemsBefore > itemsBefore.size && + edit is Edit.Remove && + edit.index == 0 + ) { + // 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) + itemsBefore.add(loadedToPlaceholder(removed)) + edit.count-- + } + } + + // Attempt to absorb this change into the after range. This reduces structural updates. + if ( + newItemsAfter < itemsAfter.size && + edit is Edit.Insert && + edit.index == loadedCells.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)) + edit.index++ + } + } else if ( + newItemsAfter > itemsAfter.size && + edit is Edit.Remove && + edit.index + edit.count == loadedCells.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() + itemsAfter.add(0, loadedToPlaceholder(removed)) + edit.count-- + } + } + + // Process regular edits. + when (edit) { + 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])) + + // Publish a structural change. + insertRows(index, 1) + } + } + + is Edit.Move -> { + // TODO(jwilson): support moves! + error("move unsupported!") + } + + is Edit.Remove -> { + for (i in edit.index until edit.index + edit.count) { + val index = itemsBefore.size + edit.index + loadedCells.removeAt(edit.index) + + // Publish a structural change. + deleteRows(index, 1) + } + } + } + } + + when { + newItemsBefore < itemsBefore.size -> { + // Shrink the before window. + val delta = itemsBefore.size - newItemsBefore + itemsBefore.removeRange(0, delta) + deleteRows(0, delta) + } + newItemsBefore > itemsBefore.size -> { + // Grow the before window. + val delta = newItemsBefore - itemsBefore.size + itemsBefore.addNulls(0, delta) + insertRows(0, delta) + } + } + + when { + newItemsAfter < itemsAfter.size -> { + // Shrink the after window. + val delta = itemsAfter.size - newItemsAfter + val index = itemsBefore.size + loadedCells.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 + itemsAfter.addNulls(itemsAfter.size, delta) + insertRows(index, delta) + } + } + + edits.clear() + } + + private fun placeholderToLoaded( + placeholder: Cell?, + widget: Widget, + ): Cell { + // No placeholder for this index. Create a new cell. + if (placeholder == null) { + return Cell(this, widget) + } + + // We have a placeholder. Promote it to a loaded cell. + require(placeholder.isPlaceholder) + placeholdersQueue += placeholder.widget + placeholder.isPlaceholder = false + placeholder.widget = widget + setWidget(placeholder.view!!, widget) + return placeholder + } + + private fun loadedToPlaceholder(loaded: Cell): Cell? { + require(!loaded.isPlaceholder) + + // If there's no UI for this cell, we're done. + val ui = loaded.view ?: return null + + // Replace the loaded UI with a placeholder. + val widget = takePlaceholder() + setWidget(ui, widget) + loaded.widget = widget + 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) + } + } + + private fun getLoadedCell(index: Int): C { + val result = loadedCells[index - itemsBefore.size] + + var view = result.view + if (view == null) { + // Bind the cell to this view. + view = createCell(result, result.widget, index) + result.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!! + + // Create a new placeholder cell and bind it to the view. + result = Cell( + processor = this, + widget = takePlaceholder(), + isPlaceholder = true, + ) + val view = createCell(result, result.widget, cellIndex) + result.bind(view) + placeholders.set(placeholderIndex, result) + return view + } + + private fun takePlaceholder(): Widget { + return placeholdersQueue.removeFirstOrNull() + ?: 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) + + /** + * This class keeps track of whether a cell is bound to an on-screen UI. + * + * While it's bound to the UI, its contents 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. + * + * 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. + */ + public class Cell internal constructor( + internal val processor: LazyListUpdateProcessor, + internal var widget: Widget, + internal var isPlaceholder: Boolean = false, + ) { + public var view: C? = null + private set + + private var bindCount = 0 + private var unbindCount = 0 + + public val isBound: Boolean + get() = bindCount > unbindCount + + public fun bind(view: C) { + require(bindCount == unbindCount) { "already bound" } + + bindCount++ + this.view = view + } + + public fun unbind() { + if (bindCount == unbindCount) return + unbindCount++ + + // Detach the display. + view = null + + if (isPlaceholder) { + // This placeholder is no longer needed. + val itemsBeforeIndex = processor.itemsBefore.indexOfFirst { it === this } + if (itemsBeforeIndex != -1) processor.itemsBefore.set(itemsBeforeIndex, null) + + val itemsAfterIndex = processor.itemsAfter.indexOfFirst { it === this } + if (itemsAfterIndex != -1) processor.itemsAfter.set(itemsAfterIndex, null) + + // When a placeholder is reused, recycle its widget. + processor.placeholdersQueue += widget + } + } + } + + /** Note that edit instances are mutable. This avoids allocations during scrolling. */ + private sealed class Edit { + class Insert(var index: Int, val widgets: MutableList>) : Edit() + class Move(val fromIndex: Int, val toIndex: Int, val count: Int) : Edit() + class Remove(var index: Int, var count: Int) : Edit() + } +} 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 new file mode 100644 index 0000000000..570acea35a --- /dev/null +++ b/redwood-lazylayout-widget/src/commonMain/kotlin/app/cash/redwood/lazylayout/widget/SparseList.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * 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 app.cash.redwood.lazylayout.widget + +internal class SparseList : AbstractList() { + /** The non-null elements of this sparse list. */ + private val elements = mutableListOf() + + /** + * This contains the external indexes of the values in [elements]. The last element is the size of + * this sparse list. + * + * For example, given this list: + * null, null, "A", null, "B", null, null + * + * The values in [elements] are: + * ["A", "B"] + * + * The values in [externalIndexes] are: + * [2, 4, 7] + */ + private val externalIndexes = mutableListOf(0) + + override val size: Int + get() = externalIndexes.last() + + override fun get(index: Int): T? { + require(index in 0 until size) + val internalIndex = externalIndexes.binarySearch(index) + return when { + internalIndex < 0 -> null + else -> elements[internalIndex] + } + } + + fun removeLast(): T? { + return removeAt(size - 1) + } + + fun removeAt(index: Int): T? { + require(index in 0 until size) + val searchIndex = externalIndexes.binarySearch(index) + return when { + searchIndex < 0 -> { + for (i in -1 - searchIndex until externalIndexes.size) { + externalIndexes[i]-- + } + null + } + else -> { + externalIndexes.removeAt(searchIndex) + for (i in searchIndex until externalIndexes.size) { + externalIndexes[i]-- + } + elements.removeAt(searchIndex) + } + } + } + + fun add(value: T) { + if (value != null) { + elements += value + externalIndexes += externalIndexes.last() + 1 + } else { + externalIndexes[externalIndexes.size - 1]++ + } + } + + fun add(index: Int, value: T) { + val insertIndex = insertIndex(index) + + val shiftFrom = when { + value != null -> { + elements.add(insertIndex, value) + externalIndexes.add(insertIndex, index) + insertIndex + 1 + } + else -> insertIndex + } + + for (i in shiftFrom until externalIndexes.size) { + externalIndexes[i]++ + } + } + + fun set(index: Int, value: T) { + removeAt(index) + add(index, value) + } + + fun removeRange(fromIndex: Int, toIndex: Int) { + val delta = toIndex - fromIndex + val fromInternalIndex = insertIndex(fromIndex) + val toInternalIndex = insertIndex(toIndex) + + externalIndexes.subList(fromInternalIndex, toInternalIndex).clear() + elements.subList(fromInternalIndex, toInternalIndex).clear() + for (i in fromInternalIndex until externalIndexes.size) { + externalIndexes[i] -= delta + } + } + + fun addNulls(index: Int, count: Int) { + for (i in insertIndex(index) until externalIndexes.size) { + externalIndexes[i] += count + } + } + + private fun insertIndex(index: Int): Int { + val searchIndex = externalIndexes.binarySearch(index) + return when { + searchIndex >= 0 -> searchIndex + else -> -1 - searchIndex + } + } + + override fun iterator(): Iterator { + return object : AbstractIterator() { + var nextExternalIndex = 0 + var nextInternalIndex = 0 + + override fun computeNext() { + val limit = externalIndexes[nextInternalIndex] + when { + // Return a null. + nextExternalIndex < limit -> { + setNext(null) + nextExternalIndex++ + return + } + // Return a non-null element and bump the internal index. + nextInternalIndex < elements.size -> { + setNext(elements[nextInternalIndex]) + nextInternalIndex++ + nextExternalIndex++ + } + else -> done() + } + } + } + } +} 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 new file mode 100644 index 0000000000..f5897c12a4 --- /dev/null +++ b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/FakeProcessor.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * 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 app.cash.redwood.lazylayout.widget + +import app.cash.redwood.widget.Widget + +/** + * This fake simulates a real scroll window, which is completely independent of the window of loaded + * items. Tests should call [scrollTo] to move the scroll window. + */ +class FakeProcessor : LazyListUpdateProcessor() { + private var dataSize = 0 + 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 + } + + override fun insertRows(index: Int, count: Int) { + require(index >= 0 && count >= 0 && index <= dataSize + 1) + require(dataSize + count == super.size) + dataSize += count + + for (i in index until index + count) { + val scrollWindowLimit = scrollWindowOffset + scrollWindowCells.size + if (i < scrollWindowOffset) { + // Shift the scroll window down by one. + scrollWindowOffset++ + } 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() + } + } + } + + override fun deleteRows(index: Int, count: Int) { + require(index >= 0 && count >= 0 && index + count <= dataSize) + require(dataSize - count == super.size) + dataSize -= count + + val originalScrollWindowSize = scrollWindowCells.size + for (i in 0 until count) { + val scrollWindowLimit = scrollWindowOffset + scrollWindowCells.size + if (index < scrollWindowOffset) { + // Shift the scroll window up by one. + scrollWindowOffset-- + } else if (index < scrollWindowLimit) { + // Delete a cell from the scroll window. We'll replenish the window below. + scrollWindowCells.removeAt(index - scrollWindowOffset).cell.unbind() + } + } + + // Preserve the scroll window's size if possible. + // Note that this doesn't scroll up to force a constant size. + while ( + scrollWindowCells.size < originalScrollWindowSize && + scrollWindowOffset + scrollWindowCells.size < dataSize + ) { + scrollWindowCells.add(getCell(scrollWindowOffset + scrollWindowCells.size)) + } + } + + fun scrollTo(offset: Int, count: Int) { + val oldWindowCells = ArrayDeque(scrollWindowCells) + var oldWindowCellsOffset = scrollWindowOffset + + scrollWindowOffset = offset + scrollWindowCells.clear() + + // Recycle old cells that precede the new window. + while (oldWindowCellsOffset < offset) { + if (oldWindowCells.isNotEmpty()) oldWindowCells.removeFirst().cell.unbind() + oldWindowCellsOffset++ + } + + // Populate the new window with either old or new cells. + for (i in offset until offset + count) { + if (i in oldWindowCellsOffset until oldWindowCellsOffset + oldWindowCells.size) { + scrollWindowCells += oldWindowCells.removeFirst() + oldWindowCellsOffset++ + } else { + scrollWindowCells += getCell(i) + } + } + + // Recycle old cells that are beyond the new window. + while (oldWindowCells.isNotEmpty()) { + oldWindowCells.removeFirst().cell.unbind() + } + } + + override fun toString(): String { + val cellsBefore = scrollWindowOffset + val cellsAfter = dataSize - cellsBefore - scrollWindowCells.size + return scrollWindowCells.joinToString( + prefix = when { + cellsBefore > 0 -> "[$cellsBefore...] " + else -> "" + }, + postfix = when { + cellsAfter > 0 -> " [...$cellsAfter]" + else -> "" + }, + separator = " ", + ) { + it.widget.value + } + } + + class StringCell( + val cell: Cell, + var widget: Widget, + ) +} diff --git a/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessorTest.kt b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessorTest.kt new file mode 100644 index 0000000000..3ba83ba8da --- /dev/null +++ b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/LazyListUpdateProcessorTest.kt @@ -0,0 +1,380 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * 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 app.cash.redwood.lazylayout.widget + +import app.cash.redwood.Modifier +import app.cash.redwood.widget.Widget +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class LazyListUpdateProcessorTest { + private val processor = FakeProcessor() + .apply { + for (i in 0 until 10) { + placeholder.insert(i, StringWidget(".")) + } + } + + @Test + fun allPlaceholders() { + processor.itemsBefore(0) + processor.itemsAfter(10) + processor.onEndChanges() + + processor.scrollTo(0, 10) + assertThat(processor.toString()).isEqualTo(". . . . . . . . . .") + } + + @Test + fun initiallyEmpty() { + processor.itemsBefore(0) + processor.itemsAfter(0) + processor.onEndChanges() + + assertThat(processor.toString()).isEqualTo("") + } + + /** + * We've got a fixed set of loaded data: + * + * . . . D E F G H . . . . + * + * Scroll a 5-element window up and down over this region and confirm we see the loaded data. + */ + @Test + fun moveScrollWindowDownAndUp() { + processor.itemsBefore(3) + processor.itemsAfter(4) + processor.items.insert(0, StringWidget("D")) + processor.items.insert(1, StringWidget("E")) + processor.items.insert(2, StringWidget("F")) + processor.items.insert(3, StringWidget("G")) + processor.items.insert(4, StringWidget("H")) + processor.onEndChanges() + + processor.scrollTo(0, 5) + assertThat(processor.toString()).isEqualTo(". . . D E [...7]") + + processor.scrollTo(2, 5) + assertThat(processor.toString()).isEqualTo("[2...] . D E F G [...5]") + + processor.scrollTo(4, 5) + assertThat(processor.toString()).isEqualTo("[4...] E F G H . [...3]") + + processor.scrollTo(6, 5) + assertThat(processor.toString()).isEqualTo("[6...] G H . . . [...1]") + + processor.scrollTo(7, 5) + assertThat(processor.toString()).isEqualTo("[7...] H . . . .") + + processor.scrollTo(6, 5) + assertThat(processor.toString()).isEqualTo("[6...] G H . . . [...1]") + + processor.scrollTo(4, 5) + assertThat(processor.toString()).isEqualTo("[4...] E F G H . [...3]") + + processor.scrollTo(2, 5) + assertThat(processor.toString()).isEqualTo("[2...] . D E F G [...5]") + + processor.scrollTo(0, 5) + assertThat(processor.toString()).isEqualTo(". . . D E [...7]") + } + + /** + * We've got a fixed 4-element scroll position at the front of a dataset. Move the 5-element + * loaded window across this space. + */ + @Test + fun moveLoadedWindowDownAndUpWithScrollPositionAtFront() { + processor.itemsBefore(0) + processor.itemsAfter(7) + processor.items.insert(0, StringWidget("A")) + processor.items.insert(1, StringWidget("B")) + processor.items.insert(2, StringWidget("C")) + processor.items.insert(3, StringWidget("D")) + processor.items.insert(4, StringWidget("E")) + processor.onEndChanges() + + processor.scrollTo(0, 4) + assertThat(processor.toString()).isEqualTo("A B C D [...8]") + + processor.itemsBefore(2) + processor.itemsAfter(5) + processor.items.insert(5, StringWidget("F")) + processor.items.insert(6, StringWidget("G")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo(". . C D [...8]") + + processor.itemsBefore(4) + processor.itemsAfter(3) + processor.items.insert(5, StringWidget("H")) + processor.items.insert(6, StringWidget("I")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo(". . . . [...8]") + + processor.itemsBefore(6) + processor.itemsAfter(1) + processor.items.insert(5, StringWidget("J")) + processor.items.insert(6, StringWidget("K")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo(". . . . [...8]") + + processor.itemsBefore(4) + processor.itemsAfter(3) + processor.items.insert(0, StringWidget("E")) + processor.items.insert(1, StringWidget("F")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo(". . . . [...8]") + + processor.itemsBefore(2) + processor.itemsAfter(5) + processor.items.insert(0, StringWidget("C")) + processor.items.insert(1, StringWidget("D")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo(". . C D [...8]") + + processor.itemsBefore(0) + processor.itemsAfter(7) + processor.items.insert(0, StringWidget("A")) + processor.items.insert(1, StringWidget("B")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("A B C D [...8]") + } + + /** + * We've got a fixed 4-element scroll position at the end of a dataset. Move the 5-element + * loaded window across this space. + */ + @Test + fun moveLoadedWindowDownAndUpWithScrollPositionAtEnd() { + processor.itemsBefore(0) + processor.itemsAfter(7) + processor.items.insert(0, StringWidget("A")) + processor.items.insert(1, StringWidget("B")) + processor.items.insert(2, StringWidget("C")) + processor.items.insert(3, StringWidget("D")) + processor.items.insert(4, StringWidget("E")) + processor.onEndChanges() + + processor.scrollTo(8, 4) + assertThat(processor.toString()).isEqualTo("[8...] . . . .") + + processor.itemsBefore(2) + processor.itemsAfter(5) + processor.items.insert(5, StringWidget("F")) + processor.items.insert(6, StringWidget("G")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] . . . .") + + processor.itemsBefore(4) + processor.itemsAfter(3) + processor.items.insert(5, StringWidget("H")) + processor.items.insert(6, StringWidget("I")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I . . .") + + processor.itemsBefore(6) + processor.itemsAfter(1) + processor.items.insert(5, StringWidget("J")) + processor.items.insert(6, StringWidget("K")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J K .") + + processor.itemsBefore(7) + processor.itemsAfter(0) + processor.items.insert(5, StringWidget("L")) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J K L") + + processor.itemsBefore(5) + processor.itemsAfter(2) + processor.items.insert(0, StringWidget("G")) + processor.items.insert(1, StringWidget("H")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J . .") + + processor.itemsBefore(3) + processor.itemsAfter(4) + processor.items.insert(0, StringWidget("E")) + processor.items.insert(1, StringWidget("F")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] . . . .") + + processor.itemsBefore(1) + processor.itemsAfter(6) + processor.items.insert(0, StringWidget("C")) + processor.items.insert(1, StringWidget("D")) + processor.items.remove(5, 1) + processor.items.remove(5, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] . . . .") + } + + @Test + fun moveLoadedWindowDownAndUp2() { + // Load the first 10. + processor.itemsAfter(10) + processor.items.insert(0, StringWidget("A")) + processor.items.insert(1, StringWidget("B")) + processor.items.insert(2, StringWidget("C")) + processor.items.insert(3, StringWidget("D")) + processor.items.insert(4, StringWidget("E")) + processor.items.insert(5, StringWidget("F")) + processor.items.insert(6, StringWidget("G")) + processor.items.insert(7, StringWidget("H")) + processor.items.insert(8, StringWidget("I")) + processor.items.insert(9, StringWidget("J")) + processor.onEndChanges() + processor.scrollTo(8, 5) + assertThat(processor.toString()).isEqualTo("[8...] I J . . . [...7]") + + // Load the middle 10. + processor.itemsBefore(5) + processor.itemsAfter(5) + processor.items.insert(10, StringWidget("K")) + processor.items.insert(11, StringWidget("L")) + processor.items.insert(12, StringWidget("M")) + processor.items.insert(13, StringWidget("N")) + processor.items.insert(14, StringWidget("O")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J K L M [...7]") + + // Load the bottom 10. + processor.itemsBefore(10) + processor.itemsAfter(0) + processor.items.insert(10, StringWidget("P")) + processor.items.insert(11, StringWidget("Q")) + processor.items.insert(12, StringWidget("R")) + processor.items.insert(13, StringWidget("S")) + processor.items.insert(14, StringWidget("T")) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.items.remove(0, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] . . K L M [...7]") + + // Load the middle 10. + processor.itemsBefore(5) + processor.itemsAfter(5) + processor.items.insert(0, StringWidget("F")) + processor.items.insert(1, StringWidget("G")) + processor.items.insert(2, StringWidget("H")) + processor.items.insert(3, StringWidget("I")) + processor.items.insert(4, StringWidget("J")) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J K L M [...7]") + + // Load the first 10. + processor.itemsBefore(0) + processor.itemsAfter(10) + processor.items.insert(0, StringWidget("A")) + processor.items.insert(1, StringWidget("B")) + processor.items.insert(2, StringWidget("C")) + processor.items.insert(3, StringWidget("D")) + processor.items.insert(4, StringWidget("E")) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.items.remove(10, 1) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[8...] I J . . . [...7]") + } + + @Test + fun itemsBeforeGrowsAndShrinks() { + processor.itemsBefore(5) + processor.itemsAfter(5) + processor.items.insert(0, StringWidget("F")) + processor.items.insert(1, StringWidget("G")) + processor.items.insert(2, StringWidget("H")) + processor.onEndChanges() + + processor.scrollTo(4, 5) + assertThat(processor.toString()).isEqualTo("[4...] . F G H . [...4]") + + processor.itemsBefore(2) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[1...] . F G H . [...4]") + + processor.itemsBefore(5) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[4...] . F G H . [...4]") + } + + @Test + fun itemsAfterGrowsAndShrinks() { + processor.itemsBefore(5) + processor.itemsAfter(5) + processor.items.insert(0, StringWidget("F")) + processor.items.insert(1, StringWidget("G")) + processor.items.insert(2, StringWidget("H")) + processor.onEndChanges() + + processor.scrollTo(4, 5) + assertThat(processor.toString()).isEqualTo("[4...] . F G H . [...4]") + + processor.itemsAfter(2) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[4...] . F G H . [...1]") + + processor.itemsAfter(5) + processor.onEndChanges() + assertThat(processor.toString()).isEqualTo("[4...] . F G H . [...4]") + } + + class StringWidget( + override var value: String, + ) : Widget { + override var modifier: Modifier = Modifier + } +} diff --git a/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/SparseListTest.kt b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/SparseListTest.kt new file mode 100644 index 0000000000..489597dab5 --- /dev/null +++ b/redwood-lazylayout-widget/src/commonTest/kotlin/app/cash/redwood/lazylayout/widget/SparseListTest.kt @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * 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 app.cash.redwood.lazylayout.widget + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class SparseListTest { + @Test + fun nonNulls() { + val list = SparseList() + list.add("A") + list.add("B") + list.add("C") + assertThat(list[0]).isEqualTo("A") + assertThat(list[1]).isEqualTo("B") + assertThat(list[2]).isEqualTo("C") + assertThat(list.size).isEqualTo(3) + assertThat(list.toList()).containsExactly("A", "B", "C") + } + + @Test + fun emptyList() { + val list = SparseList() + assertThat(list.size).isEqualTo(0) + assertThat(list.toList()).containsExactly() + } + + @Test + fun singleNonNull() { + val list = SparseList() + list.add("A") + assertThat(list[0]).isEqualTo("A") + assertThat(list.size).isEqualTo(1) + assertThat(list.toList()).containsExactly("A") + } + + @Test + fun singleNull() { + val list = SparseList() + list.add(null) + assertThat(list[0]).isEqualTo(null) + assertThat(list.size).isEqualTo(1) + assertThat(list.toList()).containsExactly(null) + } + + @Test + fun allNulls() { + val list = SparseList() + list.add(null) + list.add(null) + list.add(null) + assertThat(list[0]).isEqualTo(null) + assertThat(list[1]).isEqualTo(null) + assertThat(list[2]).isEqualTo(null) + assertThat(list.size).isEqualTo(3) + assertThat(list.toList()).containsExactly(null, null, null) + } + + @Test + fun mixOfNonNullsAndNulls() { + val list = SparseList() + list.add(null) + list.add(null) + list.add("A") + list.add(null) + list.add("B") + list.add(null) + list.add(null) + list.add(null) + list.add("C") + list.add(null) + list.add(null) + assertThat(list[0]).isEqualTo(null) + assertThat(list[1]).isEqualTo(null) + assertThat(list[2]).isEqualTo("A") + assertThat(list[3]).isEqualTo(null) + assertThat(list[4]).isEqualTo("B") + assertThat(list[5]).isEqualTo(null) + assertThat(list[6]).isEqualTo(null) + assertThat(list[7]).isEqualTo(null) + assertThat(list[8]).isEqualTo("C") + assertThat(list[9]).isEqualTo(null) + assertThat(list[10]).isEqualTo(null) + assertThat(list.size).isEqualTo(11) + assertThat(list.toList()).containsExactly( + null, null, "A", null, "B", null, null, null, "C", null, null, + ) + } + + @Test + fun removeAtRemovesNull() { + val list = SparseList() + list.add(null) + list.add(null) + list.add("A") + list.add(null) + list.add(null) + list.add("B") + list.add(null) + + assertThat(list.removeAt(3)).isEqualTo(null) + assertThat(list.toList()).containsExactly(null, null, "A", null, "B", null) + + assertThat(list.removeAt(3)).isEqualTo(null) + assertThat(list.toList()).containsExactly(null, null, "A", "B", null) + + assertThat(list.removeAt(4)).isEqualTo(null) + assertThat(list.toList()).containsExactly(null, null, "A", "B") + + assertThat(list.removeAt(0)).isEqualTo(null) + assertThat(list.toList()).containsExactly(null, "A", "B") + + assertThat(list.removeAt(0)).isEqualTo(null) + assertThat(list.toList()).containsExactly("A", "B") + } + + @Test + fun removeAtRemovesNonNull() { + val list = SparseList() + list.add(null) + list.add(null) + list.add("A") + list.add(null) + list.add(null) + list.add("B") + list.add(null) + + assertThat(list.removeAt(2)).isEqualTo("A") + assertThat(list.toList()).containsExactly(null, null, null, null, "B", null) + + assertThat(list.removeAt(4)).isEqualTo("B") + assertThat(list.toList()).containsExactly(null, null, null, null, null) + } + + @Test + fun removeAtRemovesOnlyNullElement() { + val list = SparseList() + list.add(null) + + assertThat(list.removeAt(0)).isEqualTo(null) + assertThat(list.toList()).containsExactly() + } + + @Test + fun removeAtRemovesOnlyNonNullElement() { + val list = SparseList() + list.add("A") + + assertThat(list.removeAt(0)).isEqualTo("A") + assertThat(list.toList()).containsExactly() + } + + @Test + fun addNullAtIndexWithIndexHit() { + val list = SparseList() + list.add(null) + list.add("A") + list.add(null) + list.add("B") + list.add(null) + list.add(3, null) + + assertThat(list.size).isEqualTo(6) + assertThat(list.toList()).containsExactly(null, "A", null, null, "B", null) + } + + @Test + fun addNullAtIndexWithIndexMiss() { + val list = SparseList() + list.add(null) + list.add("A") + list.add(null) + list.add("B") + list.add(null) + list.add(2, null) + + assertThat(list.size).isEqualTo(6) + assertThat(list.toList()).containsExactly(null, "A", null, null, "B", null) + } + + @Test + fun addNonNullAtIndexWithIndexHit() { + val list = SparseList() + list.add(null) + list.add("A") + list.add(null) + list.add("B") + list.add(null) + list.add(3, "X") + + assertThat(list.size).isEqualTo(6) + assertThat(list.toList()).containsExactly(null, "A", null, "X", "B", null) + } + + @Test + fun addNonNullAtIndexWithIndexMiss() { + val list = SparseList() + list.add(null) + list.add("A") + list.add(null) + list.add("B") + list.add(null) + list.add(2, "X") + + assertThat(list.size).isEqualTo(6) + assertThat(list.toList()).containsExactly(null, "A", "X", null, "B", null) + } + + @Test + fun addNullsBetweenNonNulls() { + val list = SparseList() + list.add(null) + list.add("A") + list.add("B") + list.add(null) + list.addNulls(2, 3) + + assertThat(list.size).isEqualTo(7) + assertThat(list.toList()).containsExactly(null, "A", null, null, null, "B", null) + } + + @Test + fun addNullsBetweenNulls() { + val list = SparseList() + list.add("A") + list.add(null) + list.add(null) + list.add("B") + list.addNulls(2, 3) + + assertThat(list.size).isEqualTo(7) + assertThat(list.toList()).containsExactly("A", null, null, null, null, null, "B") + } + + @Test + fun removeRangeRemovesNonNulls() { + val list = SparseList() + list.add(null) + list.add("A") + list.add("B") + list.add("C") + list.add(null) + list.removeRange(1, 4) + + assertThat(list.size).isEqualTo(2) + assertThat(list.toList()).containsExactly(null, null) + } + + @Test + fun removeRangeRemovesNulls() { + val list = SparseList() + list.add("A") + list.add(null) + list.add(null) + list.add(null) + list.add("B") + list.removeRange(1, 4) + + assertThat(list.size).isEqualTo(2) + assertThat(list.toList()).containsExactly("A", "B") + } + + @Test + fun removeRangeRemovesNullsAndNonNulls() { + val list = SparseList() + list.add(null) + list.add("A") + list.add(null) + list.add(null) + list.add("B") + list.add(null) + list.add("C") + list.removeRange(1, 6) + + assertThat(list.size).isEqualTo(2) + assertThat(list.toList()).containsExactly(null, "C") + } +}