Skip to content

Commit

Permalink
Support animated programmatic scrolls (#1624)
Browse files Browse the repository at this point in the history
In emoji search when the search terms is changed we scroll
to the top of the list. This means we need to treat
programmatic scrolls as something that can happen repeatedly,
not just when we restore a lazy list.

This moves the feature that prevents programmatic scrolls
from colliding with user scrolls from host code to guest code.
The benefit is it no longer risks getting the loaded window
out of sync with the true scroll window.

This also implements all scroll behavior for iOS.
  • Loading branch information
squarejesse authored Oct 23, 2023
1 parent 5ab058a commit 0f65d12
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import kotlinx.serialization.Serializable
@[Immutable Serializable]
@Poko
public class ScrollItemIndex(
@Suppress("unused") private val id: Int,
public val id: Int,
public val index: Int,
/** True to smoothly scroll to the new position. */
public val animated: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class ScrollItemIndexSerializationTest {
ScrollItemIndex(3, 7),
"""{"id":3,"index":7}""",
)
assertRoundTrip(
ScrollItemIndex(3, 7, animated = true),
"""{"id":3,"index":7,"animated":true}""",
)
}

private fun assertRoundTrip(value: ScrollItemIndex, encoded: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
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.ui.Margin
import kotlin.jvm.JvmName

Expand Down Expand Up @@ -70,7 +69,7 @@ internal fun LazyList(
margin = margin,
crossAxisAlignment = crossAxisAlignment,
modifier = modifier,
scrollItemIndex = ScrollItemIndex(0, state.programmaticScrollIndex),
scrollItemIndex = state.programmaticScrollIndex,
placeholder = { repeat(placeholderPoolSize) { placeholder() } },
items = {
for (index in itemsBefore until itemCount - itemsAfter) {
Expand Down Expand Up @@ -123,7 +122,7 @@ internal fun RefreshableLazyList(
margin = margin,
crossAxisAlignment = crossAxisAlignment,
modifier = modifier,
scrollItemIndex = ScrollItemIndex(0, state.programmaticScrollIndex),
scrollItemIndex = state.programmaticScrollIndex,
placeholder = { repeat(placeholderPoolSize) { placeholder() } },
pullRefreshContentColor = pullRefreshContentColor,
items = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import app.cash.redwood.lazylayout.api.ScrollItemIndex

@Composable
public fun rememberLazyListState(): LazyListState {
Expand All @@ -34,39 +35,57 @@ private val saver: Saver<LazyListState, *> = Saver(
save = { it.firstIndex },
restore = {
LazyListState().apply {
programmaticScroll(it)
programmaticScroll(firstIndex = it, animated = false, clobberUserScroll = false)
}
},
)

public open class LazyListState {
/**
* Update this to trigger a programmatic scroll. Typically this is updated exactly once, when the
* previous scroll state is restored.
* Update this to trigger a programmatic scroll. This may be updated multiple times, including
* when the previous scroll state is restored.
*/
public var programmaticScrollIndex: Int by mutableStateOf(0)
public var programmaticScrollIndex: ScrollItemIndex by mutableStateOf(
ScrollItemIndex(id = 0, index = 0, animated = false),
)
private set

/** Once we receive a user scroll, we limit which programmatic scrolls we apply. */
private var userScrolled = false

/** Bounds of what the user is looking at. Everything else is placeholders! */
public var firstIndex: Int by mutableStateOf(0)
private set
public var lastIndex: Int by mutableStateOf(0)
private set

/** Perform a programmatic scroll. */
public fun programmaticScroll(index: Int) {
require(index >= 0)
require(programmaticScrollIndex == 0) { "unexpected double restoreIndex()" }
public fun programmaticScroll(
firstIndex: Int,
animated: Boolean,
clobberUserScroll: Boolean = true,
) {
require(firstIndex >= 0)
if (!clobberUserScroll && userScrolled) return

this.programmaticScrollIndex = index
val previous = programmaticScrollIndex
this.programmaticScrollIndex = ScrollItemIndex(
id = previous.id + 1,
index = firstIndex,
animated = animated,
)

val delta = (lastIndex - firstIndex)
this.firstIndex = index
this.lastIndex = index + delta
val delta = (lastIndex - this.firstIndex)
this.firstIndex = firstIndex
this.lastIndex = firstIndex + delta
}

/** React to a user-initiated scroll. */
public fun onUserScroll(firstIndex: Int, lastIndex: Int) {
if (firstIndex > 0) {
userScrolled = true
}

this.firstIndex = firstIndex
this.lastIndex = lastIndex
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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.LazyListScrollProcessor
import app.cash.redwood.lazylayout.widget.LazyListUpdateProcessor
import app.cash.redwood.lazylayout.widget.LazyListUpdateProcessor.Binding
import app.cash.redwood.lazylayout.widget.RefreshableLazyList
Expand Down Expand Up @@ -71,9 +72,7 @@ internal open class UIViewLazyList(
override val value: UIView
get() = tableView

protected var onViewportChanged: ((firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit)? = null

private val processor = object : LazyListUpdateProcessor<LazyListContainerCell, UIView>() {
private val updateProcessor = object : LazyListUpdateProcessor<LazyListContainerCell, UIView>() {
override fun insertRows(index: Int, count: Int) {
// TODO(jwilson): pass a range somehow when 'count' is large?
tableView.insertRowsAtIndexPaths(
Expand All @@ -95,25 +94,40 @@ internal open class UIViewLazyList(
}
}

override val placeholder: Widget.Children<UIView> = processor.placeholder
private var isDoingProgrammaticScroll = false

private val scrollProcessor = object : LazyListScrollProcessor() {
override fun contentSize() = updateProcessor.size

override fun programmaticScroll(firstIndex: Int, animated: Boolean) {
isDoingProgrammaticScroll = animated // Don't forward scroll updates to scrollProcessor.
tableView.scrollToRowAtIndexPath(
NSIndexPath.indexPathForItem(firstIndex.toLong(), 0),
UITableViewScrollPosition.UITableViewScrollPositionTop,
animated = animated,
)
}
}

override val placeholder: Widget.Children<UIView> = updateProcessor.placeholder

override val items: Widget.Children<UIView> = processor.items
override val items: Widget.Children<UIView> = updateProcessor.items

private val dataSource = object : NSObject(), UITableViewDataSourceProtocol {
override fun tableView(
tableView: UITableView,
numberOfRowsInSection: NSInteger,
): Long {
require(numberOfRowsInSection == 0L)
return processor.size.toLong()
return updateProcessor.size.toLong()
}

override fun tableView(
tableView: UITableView,
cellForRowAtIndexPath: NSIndexPath,
): LazyListContainerCell {
val index = cellForRowAtIndexPath.item.toInt()
return processor.getOrCreateView(index) { binding ->
return updateProcessor.getOrCreateView(index) { binding ->
createView(tableView, binding, index)
}
}
Expand All @@ -136,15 +150,18 @@ internal open class UIViewLazyList(
private val tableViewDelegate: UITableViewDelegateProtocol =
object : NSObject(), UITableViewDelegateProtocol {
override fun scrollViewDidScroll(scrollView: UIScrollView) {
if (isDoingProgrammaticScroll) return // Only notify of user scrolls.

val visibleIndexPaths = tableView.indexPathsForVisibleRows ?: return
if (visibleIndexPaths.isEmpty()) return

val firstIndex = visibleIndexPaths.minOf { (it as NSIndexPath).item.toInt() }
val lastIndex = visibleIndexPaths.maxOf { (it as NSIndexPath).item.toInt() }
scrollProcessor.onUserScroll(firstIndex, lastIndex)
}

if (visibleIndexPaths.isNotEmpty()) {
// TODO: Optimize this for less operations
onViewportChanged?.invoke(
visibleIndexPaths.minOf { (it as NSIndexPath).item.toInt() },
visibleIndexPaths.maxOf { (it as NSIndexPath).item.toInt() },
)
}
override fun scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) {
isDoingProgrammaticScroll = false
}
}

Expand All @@ -165,7 +182,7 @@ internal open class UIViewLazyList(
}

final override fun onViewportChanged(onViewportChanged: (Int, Int) -> Unit) {
this.onViewportChanged = onViewportChanged
scrollProcessor.onViewportChanged(onViewportChanged)
}

override fun isVertical(isVertical: Boolean) {
Expand All @@ -191,25 +208,20 @@ internal open class UIViewLazyList(
}

override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) {
if (scrollItemIndex.index < processor.size) {
tableView.scrollToRowAtIndexPath(
NSIndexPath.indexPathForItem(scrollItemIndex.index.toLong(), 0),
UITableViewScrollPosition.UITableViewScrollPositionTop,
animated = false,
)
}
scrollProcessor.scrollItemIndex(scrollItemIndex)
}

override fun itemsBefore(itemsBefore: Int) {
processor.itemsBefore(itemsBefore)
updateProcessor.itemsBefore(itemsBefore)
}

override fun itemsAfter(itemsAfter: Int) {
processor.itemsAfter(itemsAfter)
updateProcessor.itemsAfter(itemsAfter)
}

override fun onEndChanges() {
processor.onEndChanges()
updateProcessor.onEndChanges()
scrollProcessor.onEndChanges()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.core.view.doOnDetach
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import app.cash.redwood.Modifier
Expand Down Expand Up @@ -84,11 +85,22 @@ internal open class ViewLazyList private constructor(
}
}

private var isDoingProgrammaticScroll = false

private val scrollProcessor = object : LazyListScrollProcessor() {
override fun contentSize(): Int = processor.size

override fun programmaticScroll(firstIndex: Int) {
linearLayoutManager.scrollToPositionWithOffset(firstIndex, 0)
override fun programmaticScroll(firstIndex: Int, animated: Boolean) {
isDoingProgrammaticScroll = animated
if (animated) {
val smoothScroller: RecyclerView.SmoothScroller = object : LinearSmoothScroller(recyclerView.context) {
override fun getVerticalSnapPreference(): Int = SNAP_TO_START
}
smoothScroller.targetPosition = firstIndex
linearLayoutManager.startSmoothScroll(smoothScroller)
} else {
linearLayoutManager.scrollToPositionWithOffset(firstIndex, 0)
}
}
}

Expand All @@ -108,7 +120,7 @@ internal open class ViewLazyList private constructor(
addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return
if (isDoingProgrammaticScroll) return // Only notify of user scrolls.

val firstIndex = linearLayoutManager.findFirstVisibleItemPosition()
if (firstIndex == RecyclerView.NO_POSITION) return
Expand All @@ -117,6 +129,12 @@ internal open class ViewLazyList private constructor(

scrollProcessor.onUserScroll(firstIndex, lastIndex)
}

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
isDoingProgrammaticScroll = false
}
}
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ public abstract class LazyListScrollProcessor {

/** We can't scroll to this index until we have enough data for it to display! */
private var deferredProgrammaticScrollIndex: Int = -1

/** Once we receive a user scroll, we stop forwarding programmatic scrolls. */
private var userHasScrolled = false
private var deferredProgrammaticScrollAnimated: Boolean = false

/** De-duplicate calls to [onViewportChanged]. */
private var mostRecentFirstIndex = -1
Expand All @@ -38,30 +36,27 @@ public abstract class LazyListScrollProcessor {
public fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) {
// Defer until we have data in onEndChanges().
deferredProgrammaticScrollIndex = scrollItemIndex.index
deferredProgrammaticScrollAnimated = scrollItemIndex.animated
}

public fun onEndChanges() {
// Do nothing: we don't have deferred scrolls.
if (deferredProgrammaticScrollIndex == -1) return

// Do nothing: we don't do programmatic scrolls if the user has already scrolled manually.
if (userHasScrolled) return

// Do nothing: we can't scroll to this item because it hasn't loaded yet!
if (contentSize() <= deferredProgrammaticScrollIndex) return

// Do a programmatic scroll!
programmaticScroll(deferredProgrammaticScrollIndex)
programmaticScroll(deferredProgrammaticScrollIndex, deferredProgrammaticScrollAnimated)
deferredProgrammaticScrollIndex = -1
deferredProgrammaticScrollAnimated = false
}

/**
* React to a user-initiated scroll. Callers should not call this function for programmatic
* scrolls.
*/
public fun onUserScroll(firstIndex: Int, lastIndex: Int) {
if (firstIndex > 0) userHasScrolled = true

if (firstIndex == mostRecentFirstIndex && lastIndex == mostRecentLastIndex) return

this.mostRecentFirstIndex = firstIndex
Expand All @@ -74,5 +69,5 @@ public abstract class LazyListScrollProcessor {
public abstract fun contentSize(): Int

/** Perform a programmatic scroll. */
public abstract fun programmaticScroll(firstIndex: Int)
public abstract fun programmaticScroll(firstIndex: Int, animated: Boolean)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ class FakeScrollProcessor : LazyListScrollProcessor() {

override fun contentSize(): Int = size

override fun programmaticScroll(firstIndex: Int) {
override fun programmaticScroll(firstIndex: Int, animated: Boolean) {
require(firstIndex < size)
events += "programmaticScroll($firstIndex)"
events += "programmaticScroll(firstIndex = $firstIndex, animated = $animated)"
}

fun takeEvents(): List<String> {
Expand Down
Loading

0 comments on commit 0f65d12

Please sign in to comment.