Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract an interface for LoadingStrategy #2217

Merged
merged 2 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

New:
- Source-based schema parser is now the default. Can be disabled in your schema module with `redwood { useFir = false }`.
- Introduce a `LoadingStrategy` interface to manage `LazyList` preloading.

Changed:
- Nothing yet!
Expand Down
35 changes: 24 additions & 11 deletions redwood-lazylayout-compose/api/redwood-lazylayout-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,41 @@ public abstract interface class app/cash/redwood/lazylayout/compose/LazyListScop
public class app/cash/redwood/lazylayout/compose/LazyListState {
public static final field $stable I
public fun <init> ()V
public final fun getDefaultPreloadItemCount ()I
public final fun getFirstIndex ()I
public final fun getLastIndex ()I
public final fun getPrimaryPreloadItemCount ()I
public fun <init> (Lapp/cash/redwood/lazylayout/compose/LoadingStrategy;)V
public synthetic fun <init> (Lapp/cash/redwood/lazylayout/compose/LoadingStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getProgrammaticScrollIndex ()Lapp/cash/redwood/lazylayout/api/ScrollItemIndex;
public final fun getScrollInProgressPreloadItemCount ()I
public final fun getSecondaryPreloadItemCount ()I
public final fun getStrategy ()Lapp/cash/redwood/lazylayout/compose/LoadingStrategy;
public final fun loadRange (I)Lkotlin/ranges/IntRange;
public final fun onUserScroll (II)V
public final fun programmaticScroll (IZZ)V
public static synthetic fun programmaticScroll$default (Lapp/cash/redwood/lazylayout/compose/LazyListState;IZZILjava/lang/Object;)V
public final fun setDefaultPreloadItemCount (I)V
public final fun setPrimaryPreloadItemCount (I)V
public final fun setScrollInProgressPreloadItemCount (I)V
public final fun setSecondaryPreloadItemCount (I)V
}

public final class app/cash/redwood/lazylayout/compose/LazyListStateKt {
public static final fun rememberLazyListState (Landroidx/compose/runtime/Composer;I)Lapp/cash/redwood/lazylayout/compose/LazyListState;
public static final fun rememberLazyListState (Lapp/cash/redwood/lazylayout/compose/LoadingStrategy;Landroidx/compose/runtime/Composer;II)Lapp/cash/redwood/lazylayout/compose/LazyListState;
}

public abstract interface class app/cash/redwood/lazylayout/compose/LoadingStrategy {
public abstract fun getFirstIndex ()I
public abstract fun loadRange (I)Lkotlin/ranges/IntRange;
public abstract fun onUserScroll (II)V
public abstract fun scrollTo (I)V
}

public final class app/cash/redwood/lazylayout/compose/RefreshableLazyListKt {
public static final fun RefreshableLazyList-usczW4I (ZLkotlin/jvm/functions/Function2;IIZLkotlin/jvm/functions/Function0;IILapp/cash/redwood/ui/Margin;ILapp/cash/redwood/lazylayout/api/ScrollItemIndex;ILapp/cash/redwood/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V
}

public final class app/cash/redwood/lazylayout/compose/ScrollOptimizedLoadingStrategy : app/cash/redwood/lazylayout/compose/LoadingStrategy {
public static final field $stable I
public fun <init> ()V
public fun <init> (IIIIZ)V
public synthetic fun <init> (IIIIZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun getFirstIndex ()I
public final fun getLastIndex ()I
public final fun getProgrammaticScrollIndex ()Lapp/cash/redwood/lazylayout/api/ScrollItemIndex;
public fun loadRange (I)Lkotlin/ranges/IntRange;
public fun onUserScroll (II)V
public fun scrollTo (I)V
}

50 changes: 31 additions & 19 deletions redwood-lazylayout-compose/api/redwood-lazylayout-compose.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,27 @@ package app.cash.redwood.lazylayout.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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

private const val DEFAULT_PRELOAD_ITEM_COUNT = 15
private const val SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT = 5
private const val PRIMARY_PRELOAD_ITEM_COUNT = 20
private const val SECONDARY_PRELOAD_ITEM_COUNT = 10

private const val DEFAULT_SCROLL_INDEX = -1

/**
* Creates a [LazyListState] that is remembered across compositions.
*/
@Composable
public fun rememberLazyListState(): LazyListState {
public fun rememberLazyListState(
strategy: LoadingStrategy = ScrollOptimizedLoadingStrategy(),
): LazyListState {
return rememberSaveable(saver = saver) {
LazyListState()
LazyListState(strategy)
Comment on lines 33 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like strategy needs to be added as a key to rememberSaveable. Otherwise changes in strategies would not reinitialize the state.

}
}

/** The default [Saver] implementation for [LazyListState]. */
private val saver: Saver<LazyListState, *> = Saver(
save = { it.firstIndex },
save = { it.strategy.firstIndex },
restore = {
LazyListState().apply {
programmaticScroll(firstIndex = it, animated = false, clobberUserScroll = false)
Expand All @@ -56,7 +50,9 @@ private val saver: Saver<LazyListState, *> = Saver(
*
* In most cases, this will be created via [rememberLazyListState].
*/
public open class LazyListState {
public open class LazyListState(
public val strategy: LoadingStrategy = ScrollOptimizedLoadingStrategy(),
) {
/**
* Update this to trigger a programmatic scroll. This may be updated multiple times, including
* when the previous scroll state is restored.
Expand All @@ -69,26 +65,6 @@ public open class LazyListState {
/** 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 mutableIntStateOf(0)
private set
public var lastIndex: Int by mutableIntStateOf(0)
private set

internal var preloadItems: Boolean = true

public var defaultPreloadItemCount: Int = DEFAULT_PRELOAD_ITEM_COUNT
public var scrollInProgressPreloadItemCount: Int = SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT
public var primaryPreloadItemCount: Int = PRIMARY_PRELOAD_ITEM_COUNT
public var secondaryPreloadItemCount: Int = SECONDARY_PRELOAD_ITEM_COUNT

private var firstIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
private var firstIndexFromPrevious2: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
private var lastIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)

private var beginFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
private var endFromPrevious1: Int by mutableStateOf(DEFAULT_SCROLL_INDEX)

/** Perform a programmatic scroll. */
public fun programmaticScroll(
firstIndex: Int,
Expand All @@ -98,16 +74,14 @@ public open class LazyListState {
require(firstIndex >= 0)
if (!clobberUserScroll && userScrolled) return

strategy.scrollTo(firstIndex)

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

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

/** React to a user-initiated scroll. */
Expand All @@ -116,85 +90,10 @@ public open class LazyListState {
userScrolled = true
}

this.firstIndex = firstIndex
this.lastIndex = lastIndex
strategy.onUserScroll(firstIndex, lastIndex)
}

public fun loadRange(itemCount: Int): IntRange {
// Ensure that the range includes `firstIndex` through `lastIndex`.
var begin = firstIndex
var end = lastIndex

val isScrollingDown = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 < firstIndex
val isScrollingUp = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 > firstIndex
val hasStoppedScrolling = firstIndexFromPrevious2 != DEFAULT_SCROLL_INDEX && firstIndex == firstIndexFromPrevious1
val wasScrollingDown = firstIndexFromPrevious1 > firstIndexFromPrevious2
val wasScrollingUp = firstIndexFromPrevious1 < firstIndexFromPrevious2

// Expand the range depending on scroll direction.
when {
// Ignore preloads.
!preloadItems -> {
// No-op
}

isScrollingDown -> {
begin -= scrollInProgressPreloadItemCount
end += primaryPreloadItemCount
}

isScrollingUp -> {
begin -= primaryPreloadItemCount
end += scrollInProgressPreloadItemCount
}

hasStoppedScrolling && wasScrollingDown -> {
begin -= secondaryPreloadItemCount
end += primaryPreloadItemCount
}

hasStoppedScrolling && wasScrollingUp -> {
begin -= primaryPreloadItemCount
end += secondaryPreloadItemCount
}

// New.
else -> {
end += defaultPreloadItemCount
}
}

// On initial load, set lastIndex to the end of the loaded window.
if (lastIndex == 0) {
lastIndex = end
}

// If we're contiguous with the previous visible window,
// don't rush to remove things from the previous range.
if (beginFromPrevious1 != DEFAULT_SCROLL_INDEX &&
endFromPrevious1 != DEFAULT_SCROLL_INDEX
) {
// Case one: Contiguous scroll down
if (begin in firstIndexFromPrevious1..lastIndexFromPrevious1) {
begin = beginFromPrevious1
}

// Case two: Contiguous scroll up
if (end in firstIndexFromPrevious1..lastIndexFromPrevious1) {
end = endFromPrevious1
}
}

begin = begin.coerceIn(0, itemCount)
end = end.coerceIn(0, itemCount)

this.firstIndexFromPrevious2 = firstIndexFromPrevious1
this.firstIndexFromPrevious1 = firstIndex
this.lastIndexFromPrevious1 = lastIndex

this.beginFromPrevious1 = begin
this.endFromPrevious1 = end

return begin until end
return strategy.loadRange(itemCount)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2024 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.compose

public interface LoadingStrategy {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted this because I was writing a test for some internal Cash code and I didn’t like my test accidentally exercising all of this policy.

/**
* Returns the most recent first index scrolled to. This is used to save the scroll position when
* the view is unloaded.
*/
public val firstIndex: Int

/** Perform a programmatic scroll to [firstIndex]. */
public fun scrollTo(firstIndex: Int)

/** React to a user-initiated scroll to the target range. */
public fun onUserScroll(firstIndex: Int, lastIndex: Int)

/**
* Returns the range of items to render into the view tree. This should be a slice of
* `0..(itemCount - 1)`. It should cover the most-recently scrolled to `firstIndex..lastIndex`
* range, plus any adjacent indexes to preload.
*/
public fun loadRange(itemCount: Int): IntRange
}
Loading