Skip to content

Commit

Permalink
LazyListScrollProcessor manages user and programmatic scrolls (#1619)
Browse files Browse the repository at this point in the history
* wip

* LazyListScrollProcessor manages user and programmatic scrolls

---------

Co-authored-by: Jesse Wilson <[email protected]>
  • Loading branch information
jingwei99 and squarejesse authored Oct 23, 2023
1 parent 01f7fbf commit 8afc0ba
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 75 deletions.
7 changes: 7 additions & 0 deletions redwood-lazylayout-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,12 @@ kotlin {
api libs.kotlinx.serialization.core
}
}
commonTest {
dependencies {
api libs.kotlinx.serialization.json
implementation libs.kotlin.test
implementation libs.assertk
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.api

import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.serialization.json.Json

class ScrollItemIndexSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
useArrayPolymorphism = true
}

@Test
fun encodeAndDecode() {
assertRoundTrip(
ScrollItemIndex(3, 7),
"""{"id":3,"index":7}""",
)
}

private fun assertRoundTrip(value: ScrollItemIndex, encoded: String) {
assertThat(json.encodeToString(ScrollItemIndex.serializer(), value)).isEqualTo(encoded)
assertThat(json.decodeFromString(ScrollItemIndex.serializer(), encoded)).isEqualTo(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ internal fun LazyList(
content: LazyListScope.() -> Unit,
) {
val itemProvider = rememberLazyListItemProvider(content)
var lastVisibleItemIndex by remember { mutableStateOf(0) }
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 itemCount = itemProvider.itemCount
val itemsBefore = (state.firstIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0)
val itemsAfter = (itemCount - (state.lastIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemCount)).coerceAtLeast(0)
// TODO(jwilson): drop this down to 20 once this is fixed:
// https://github.com/cashapp/redwood/issues/1551
var placeholderPoolSize by remember { mutableStateOf(30) }
Expand All @@ -56,25 +56,24 @@ internal fun LazyList(
itemsBefore = itemsBefore,
itemsAfter = itemsAfter,
onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
state.onScrolled(localFirstVisibleItemIndex)
state.onUserScroll(localFirstVisibleItemIndex, localLastVisibleItemIndex)

val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
// We only ever want to increase the pool size.
if (placeholderPoolSize < proposedPlaceholderPoolSize) {
placeholderPoolSize = proposedPlaceholderPoolSize
}
lastVisibleItemIndex = localLastVisibleItemIndex
},
width = width,
height = height,
margin = margin,
crossAxisAlignment = crossAxisAlignment,
modifier = modifier,
scrollItemIndex = ScrollItemIndex(0, state.scrollItemIndex),
scrollItemIndex = ScrollItemIndex(0, state.programmaticScrollIndex),
placeholder = { repeat(placeholderPoolSize) { placeholder() } },
items = {
for (index in itemsBefore until itemProvider.itemCount - itemsAfter) {
for (index in itemsBefore until itemCount - itemsAfter) {
key(index) {
itemProvider.Item(index)
}
Expand All @@ -99,24 +98,23 @@ internal fun RefreshableLazyList(
content: LazyListScope.() -> Unit,
) {
val itemProvider = rememberLazyListItemProvider(content)
var lastVisibleItemIndex by remember { mutableStateOf(0) }
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 itemCount = itemProvider.itemCount
val itemsBefore = (state.firstIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0)
val itemsAfter = (itemCount - (state.lastIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemCount)).coerceAtLeast(0)
var placeholderPoolSize by remember { mutableStateOf(20) }
RefreshableLazyList(
isVertical,
itemsBefore = itemsBefore,
itemsAfter = itemsAfter,
onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
state.onScrolled(localFirstVisibleItemIndex)
state.onUserScroll(localFirstVisibleItemIndex, localLastVisibleItemIndex)

val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
// We only ever want to increase the pool size.
if (placeholderPoolSize < proposedPlaceholderPoolSize) {
placeholderPoolSize = proposedPlaceholderPoolSize
}
lastVisibleItemIndex = localLastVisibleItemIndex
},
refreshing = refreshing,
onRefresh = onRefresh,
Expand All @@ -125,11 +123,11 @@ internal fun RefreshableLazyList(
margin = margin,
crossAxisAlignment = crossAxisAlignment,
modifier = modifier,
scrollItemIndex = ScrollItemIndex(0, state.scrollItemIndex),
scrollItemIndex = ScrollItemIndex(0, state.programmaticScrollIndex),
placeholder = { repeat(placeholderPoolSize) { placeholder() } },
pullRefreshContentColor = pullRefreshContentColor,
items = {
for (index in itemsBefore until itemProvider.itemCount - itemsAfter) {
for (index in itemsBefore until itemCount - itemsAfter) {
key(index) {
itemProvider.Item(index)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,55 +31,43 @@ public fun rememberLazyListState(): LazyListState {

/** The default [Saver] implementation for [LazyListState]. */
private val saver: Saver<LazyListState, *> = Saver(
save = { it.firstVisibleItemIndex },
save = { it.firstIndex },
restore = {
LazyListState().apply {
restoreIndex(it)
programmaticScroll(it)
}
},
)

public class LazyListState {

/** We only restore the scroll position once. */
private var hasRestoredScrollPosition = false

/** The scroll position to restore. */
private var restoredIndex: Int = -1

public open class LazyListState {
/**
* The value published to the host platform. This starts as 0 and changes exactly once to
* trigger exactly one scroll.
* Update this to trigger a programmatic scroll. Typically this is updated exactly once, when the
* previous scroll state is restored.
*/
public var scrollItemIndex: Int by mutableStateOf(0)
internal set
public var programmaticScrollIndex: Int by mutableStateOf(0)
private set

public var firstVisibleItemIndex: Int = 0
/** 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

public fun restoreIndex(index: Int) {
/** Perform a programmatic scroll. */
public fun programmaticScroll(index: Int) {
require(index >= 0)
require(programmaticScrollIndex == 0) { "unexpected double restoreIndex()" }

if (this.restoredIndex != -1) return
this.restoredIndex = index
this.programmaticScrollIndex = index

// Scroll to the target item.
if (hasRestoredScrollPosition) {
scrollItemIndex = restoredIndex
}
}

public fun maybeRestoreScrollPosition() {
if (this.hasRestoredScrollPosition) return
this.hasRestoredScrollPosition = true

// Scroll to the target item.
if (restoredIndex != -1) {
scrollItemIndex = restoredIndex
}
val delta = (lastIndex - firstIndex)
this.firstIndex = index
this.lastIndex = index + delta
}

public fun onScrolled(firstVisibleItemIndex: Int) {
this.firstVisibleItemIndex = firstVisibleItemIndex
/** React to a user-initiated scroll. */
public fun onUserScroll(firstIndex: Int, lastIndex: Int) {
this.firstIndex = firstIndex
this.lastIndex = lastIndex
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.annotation.ColorInt
import androidx.core.view.doOnDetach
import androidx.core.view.get
import androidx.core.view.indices
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative
import androidx.recyclerview.widget.LinearLayoutManager
Expand All @@ -38,6 +36,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 All @@ -60,8 +59,6 @@ internal open class ViewLazyList private constructor(

private var crossAxisAlignment = CrossAxisAlignment.Start

private var userHasScrolled = false

private val density = Density(recyclerView.context.resources)
private val linearLayoutManager = object : LinearLayoutManager(recyclerView.context) {
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams? = when (orientation) {
Expand All @@ -73,8 +70,6 @@ internal open class ViewLazyList private constructor(

override val value: View get() = recyclerView

private var onViewportChanged: ((Int, Int) -> Unit)? = null

private val processor = object : LazyListUpdateProcessor<ViewHolder, View>() {
override fun insertRows(index: Int, count: Int) {
adapter.notifyItemRangeInserted(index, count)
Expand All @@ -89,6 +84,14 @@ internal open class ViewLazyList private constructor(
}
}

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

override fun programmaticScroll(firstIndex: Int) {
linearLayoutManager.scrollToPositionWithOffset(firstIndex, 0)
}
}

override val items: Widget.Children<View> = processor.items

override val placeholder: Widget.Children<View> = processor.placeholder
Expand All @@ -105,22 +108,14 @@ internal open class ViewLazyList private constructor(
addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
userHasScrolled = true // Prevent guest code from hijacking the scrollbar.

var min = Int.MAX_VALUE
var max = Int.MIN_VALUE
for (i in recyclerView.indices) {
val position = recyclerView.getChildAdapterPosition(recyclerView[i])
min = minOf(min, position)
max = maxOf(max, position)
}
if (min == Int.MAX_VALUE || max == Int.MIN_VALUE) {
throw NoSuchElementException()
}
onViewportChanged?.invoke(
min.coerceAtLeast(0),
max,
)
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return

val firstIndex = linearLayoutManager.findFirstVisibleItemPosition()
if (firstIndex == RecyclerView.NO_POSITION) return
val lastIndex = linearLayoutManager.findLastVisibleItemPosition()
if (lastIndex == RecyclerView.NO_POSITION) return

scrollProcessor.onUserScroll(firstIndex, lastIndex)
}
},
)
Expand Down Expand Up @@ -166,12 +161,11 @@ internal open class ViewLazyList private constructor(
}

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

override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) {
if (userHasScrolled) return
recyclerView.scrollToPosition(scrollItemIndex.index)
scrollProcessor.scrollItemIndex(scrollItemIndex)
}

override fun isVertical(isVertical: Boolean) {
Expand All @@ -188,6 +182,7 @@ internal open class ViewLazyList private constructor(

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

private fun createLayoutParams(): FrameLayout.LayoutParams {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.lazylayout.api.ScrollItemIndex

public abstract class LazyListScrollProcessor {
/** To notify guest code of user scrolls. */
private var onViewportChanged: ((Int, Int) -> Unit)? = null

/** 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

public fun onViewportChanged(onViewportChanged: (Int, Int) -> Unit) {
this.onViewportChanged = onViewportChanged
}

public fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) {
// Defer until we have data in onEndChanges().
deferredProgrammaticScrollIndex = scrollItemIndex.index
}

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)
deferredProgrammaticScrollIndex = -1
}

/**
* 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
onViewportChanged?.invoke(firstIndex, lastIndex)
}

/** Returns the number of items we're scrolling over. */
public abstract fun contentSize(): Int

/** Perform a programmatic scroll. */
public abstract fun programmaticScroll(firstIndex: Int)
}
Loading

0 comments on commit 8afc0ba

Please sign in to comment.