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..c02d13480d 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,7 +49,9 @@ 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) } + // TODO(jwilson): drop this down to 20 once this is fixed: + // https://github.com/cashapp/redwood/issues/1551 + var placeholderPoolSize by remember { mutableStateOf(30) } LazyList( isVertical, itemsBefore = itemsBefore, 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..bfb7c279b5 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 @@ -55,7 +55,7 @@ class LazyListTest { assertThat(snapshot) .containsExactly( DefaultLazyListValue.copy( - placeholder = List(20) { TextValue(Modifier, "Placeholder") }, + placeholder = List(30) { TextValue(Modifier, "Placeholder") }, ), ) } @@ -91,7 +91,7 @@ class LazyListTest { .containsExactly( DefaultLazyListValue.copy( itemsAfter = expectedItemsAfter, - placeholder = List(20) { TextValue(Modifier, "Placeholder") }, + placeholder = List(30) { TextValue(Modifier, "Placeholder") }, items = List(expectedItemCount) { TextValue(Modifier, it.toString()) }, ), ) @@ -124,7 +124,7 @@ class LazyListTest { DefaultLazyListValue.copy( itemsBefore = 35, itemsAfter = 25, - placeholder = List(20) { TextValue(Modifier, "Placeholder") }, + placeholder = List(30) { TextValue(Modifier, "Placeholder") }, items = List(40) { TextValue(Modifier, (it + 35).toString()) }, ), ) 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..bdc6319ebe 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,81 @@ 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, + ): Long { + require(numberOfRowsInSection == 0L) + return 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 +143,28 @@ 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 + 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 +173,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 +186,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 +306,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-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") + } +}